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/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/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/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. diff --git a/approve-send-e2e.sh b/approve-send-e2e.sh new file mode 100644 index 0000000..9dfdd8a --- /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="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; } + +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/assets/ia360-bca-template-candidates/contact-sheet.jpg b/assets/ia360-bca-template-candidates/contact-sheet.jpg new file mode 100644 index 0000000..8fd5052 Binary files /dev/null and b/assets/ia360-bca-template-candidates/contact-sheet.jpg differ diff --git a/assets/ia360-bca-template-candidates/optimized/alek_presente.jpg b/assets/ia360-bca-template-candidates/optimized/alek_presente.jpg new file mode 100644 index 0000000..8a83acf Binary files /dev/null and b/assets/ia360-bca-template-candidates/optimized/alek_presente.jpg differ diff --git a/assets/ia360-bca-template-candidates/optimized/bi_solucion.jpg b/assets/ia360-bca-template-candidates/optimized/bi_solucion.jpg new file mode 100644 index 0000000..641432d Binary files /dev/null and b/assets/ia360-bca-template-candidates/optimized/bi_solucion.jpg differ 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 0000000..0d08652 Binary files /dev/null and b/assets/ia360-bca-template-candidates/optimized/dolor_ceo.jpg differ diff --git a/assets/ia360-bca-template-candidates/optimized/transformacion.jpg b/assets/ia360-bca-template-candidates/optimized/transformacion.jpg new file mode 100644 index 0000000..2bef72f Binary files /dev/null and b/assets/ia360-bca-template-candidates/optimized/transformacion.jpg differ diff --git a/backend/.env.bak-approvesend-20260609T222412Z b/backend/.env.bak-approvesend-20260609T222412Z new file mode 100644 index 0000000..d68ec9a --- /dev/null +++ b/backend/.env.bak-approvesend-20260609T222412Z @@ -0,0 +1,31 @@ +NODE_ENV=production +PORT=3011 +POSTGRES_PASSWORD=d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046 +DATABASE_URL=postgresql://postgres:d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046@forgecrm-db:5432/postgres +POSTGRES_SSL=false +REDIS_URL=redis://redis:6379 +JWT_SECRET=86ac700ea5d601085e14cd900e8579a2fa2f0ec51656c9e9cb740c7c0a649e3c +FORGECRM_ENCRYPTION_KEY=2c756c2830654efdf72e5a8baf68ab34d1808191e90defbfeb5d93c3ff164fa3 +CORS_ORIGIN=https://wa.geekstudio.dev +META_API_VERSION=v21.0 +META_WEBHOOK_VERIFY_TOKEN=7419999ee702323c2d73e917826df514 +MEDIA_DIR=/app/media +ADMIN_EMAIL=admin@forgechat.local +ADMIN_PASSWORD=y3YNoh5lEQHVcPoD/5kk3XBP + +META_APP_SECRET=e22d1929f44a077a9ee17adb2a86a98e +N8N_IA360_HANDOFF_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-whatsapp-handoff +N8N_IA360_AVAILABILITY_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-calendar-availability +N8N_IA360_BOOK_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-calendar-book +N8N_IA360_AGENT_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft +IA360_INTAKE_SECRET=06bde64ecca65b32206a2b68221d6148c4191f109b01a15f +IA360_BUSINESS_WA_NUMBER=5213321594582 + +# W6-EQUIPO-0 callback endpoint /api/internal/n8n-directive (shared secret) +IA360_DIRECTIVE_SECRET=c198c2a9071ea5a92472816d3784c619c562d6c4d60ecd7c +N8N_IA360_CONTACT_INTEL_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft + +# Canary Brain v2 (reversible, allowlist owner) +IA360_BRAIN_V2_CANARY=on +IA360_BRAIN_V2_ALLOWLIST=5213322638033 +N8N_IA360_BRAIN_V2_URL=https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test diff --git a/backend/.env.bak-contact-intel-primary- b/backend/.env.bak-contact-intel-primary- new file mode 100644 index 0000000..f000dc1 --- /dev/null +++ b/backend/.env.bak-contact-intel-primary- @@ -0,0 +1,25 @@ +NODE_ENV=production +PORT=3011 +POSTGRES_PASSWORD=d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046 +DATABASE_URL=postgresql://postgres:d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046@forgecrm-db:5432/postgres +POSTGRES_SSL=false +REDIS_URL=redis://redis:6379 +JWT_SECRET=86ac700ea5d601085e14cd900e8579a2fa2f0ec51656c9e9cb740c7c0a649e3c +FORGECRM_ENCRYPTION_KEY=2c756c2830654efdf72e5a8baf68ab34d1808191e90defbfeb5d93c3ff164fa3 +CORS_ORIGIN=https://wa.geekstudio.dev +META_API_VERSION=v21.0 +META_WEBHOOK_VERIFY_TOKEN=7419999ee702323c2d73e917826df514 +MEDIA_DIR=/app/media +ADMIN_EMAIL=admin@forgechat.local +ADMIN_PASSWORD=y3YNoh5lEQHVcPoD/5kk3XBP + +META_APP_SECRET=e22d1929f44a077a9ee17adb2a86a98e +N8N_IA360_HANDOFF_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-whatsapp-handoff +N8N_IA360_AVAILABILITY_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-calendar-availability +N8N_IA360_BOOK_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-calendar-book +N8N_IA360_AGENT_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-ai-agent +IA360_INTAKE_SECRET=06bde64ecca65b32206a2b68221d6148c4191f109b01a15f +IA360_BUSINESS_WA_NUMBER=5213321594582 + +# W6-EQUIPO-0 callback endpoint /api/internal/n8n-directive (shared secret) +IA360_DIRECTIVE_SECRET=c198c2a9071ea5a92472816d3784c619c562d6c4d60ecd7c diff --git a/backend/.env.bak-contact-intel-primary-20260605T202652Z b/backend/.env.bak-contact-intel-primary-20260605T202652Z new file mode 100644 index 0000000..f000dc1 --- /dev/null +++ b/backend/.env.bak-contact-intel-primary-20260605T202652Z @@ -0,0 +1,25 @@ +NODE_ENV=production +PORT=3011 +POSTGRES_PASSWORD=d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046 +DATABASE_URL=postgresql://postgres:d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046@forgecrm-db:5432/postgres +POSTGRES_SSL=false +REDIS_URL=redis://redis:6379 +JWT_SECRET=86ac700ea5d601085e14cd900e8579a2fa2f0ec51656c9e9cb740c7c0a649e3c +FORGECRM_ENCRYPTION_KEY=2c756c2830654efdf72e5a8baf68ab34d1808191e90defbfeb5d93c3ff164fa3 +CORS_ORIGIN=https://wa.geekstudio.dev +META_API_VERSION=v21.0 +META_WEBHOOK_VERIFY_TOKEN=7419999ee702323c2d73e917826df514 +MEDIA_DIR=/app/media +ADMIN_EMAIL=admin@forgechat.local +ADMIN_PASSWORD=y3YNoh5lEQHVcPoD/5kk3XBP + +META_APP_SECRET=e22d1929f44a077a9ee17adb2a86a98e +N8N_IA360_HANDOFF_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-whatsapp-handoff +N8N_IA360_AVAILABILITY_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-calendar-availability +N8N_IA360_BOOK_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-calendar-book +N8N_IA360_AGENT_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-ai-agent +IA360_INTAKE_SECRET=06bde64ecca65b32206a2b68221d6148c4191f109b01a15f +IA360_BUSINESS_WA_NUMBER=5213321594582 + +# W6-EQUIPO-0 callback endpoint /api/internal/n8n-directive (shared secret) +IA360_DIRECTIVE_SECRET=c198c2a9071ea5a92472816d3784c619c562d6c4d60ecd7c diff --git a/backend/.env.bak-eq0-directive-20260604T210515Z b/backend/.env.bak-eq0-directive-20260604T210515Z new file mode 100644 index 0000000..8d6f6c3 --- /dev/null +++ b/backend/.env.bak-eq0-directive-20260604T210515Z @@ -0,0 +1,22 @@ +NODE_ENV=production +PORT=3011 +POSTGRES_PASSWORD=d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046 +DATABASE_URL=postgresql://postgres:d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046@forgecrm-db:5432/postgres +POSTGRES_SSL=false +REDIS_URL=redis://redis:6379 +JWT_SECRET=86ac700ea5d601085e14cd900e8579a2fa2f0ec51656c9e9cb740c7c0a649e3c +FORGECRM_ENCRYPTION_KEY=2c756c2830654efdf72e5a8baf68ab34d1808191e90defbfeb5d93c3ff164fa3 +CORS_ORIGIN=https://wa.geekstudio.dev +META_API_VERSION=v21.0 +META_WEBHOOK_VERIFY_TOKEN=7419999ee702323c2d73e917826df514 +MEDIA_DIR=/app/media +ADMIN_EMAIL=admin@forgechat.local +ADMIN_PASSWORD=y3YNoh5lEQHVcPoD/5kk3XBP + +META_APP_SECRET=e22d1929f44a077a9ee17adb2a86a98e +N8N_IA360_HANDOFF_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-whatsapp-handoff +N8N_IA360_AVAILABILITY_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-calendar-availability +N8N_IA360_BOOK_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-calendar-book +N8N_IA360_AGENT_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-ai-agent +IA360_INTAKE_SECRET=06bde64ecca65b32206a2b68221d6148c4191f109b01a15f +IA360_BUSINESS_WA_NUMBER=5213321594582 diff --git a/backend/.env.bak-pre-agent-20260602T225247Z b/backend/.env.bak-pre-agent-20260602T225247Z new file mode 100644 index 0000000..853e517 --- /dev/null +++ b/backend/.env.bak-pre-agent-20260602T225247Z @@ -0,0 +1,19 @@ +NODE_ENV=production +PORT=3011 +POSTGRES_PASSWORD=d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046 +DATABASE_URL=postgresql://postgres:d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046@forgecrm-db:5432/postgres +POSTGRES_SSL=false +REDIS_URL=redis://redis:6379 +JWT_SECRET=86ac700ea5d601085e14cd900e8579a2fa2f0ec51656c9e9cb740c7c0a649e3c +FORGECRM_ENCRYPTION_KEY=2c756c2830654efdf72e5a8baf68ab34d1808191e90defbfeb5d93c3ff164fa3 +CORS_ORIGIN=https://wa.geekstudio.dev +META_API_VERSION=v21.0 +META_WEBHOOK_VERIFY_TOKEN=7419999ee702323c2d73e917826df514 +MEDIA_DIR=/app/media +ADMIN_EMAIL=admin@forgechat.local +ADMIN_PASSWORD=y3YNoh5lEQHVcPoD/5kk3XBP + +META_APP_SECRET=e22d1929f44a077a9ee17adb2a86a98e +N8N_IA360_HANDOFF_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-whatsapp-handoff +N8N_IA360_AVAILABILITY_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-calendar-availability +N8N_IA360_BOOK_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/ia360-calendar-book diff --git a/backend/.env.bak-pre-appsecret-20260601 b/backend/.env.bak-pre-appsecret-20260601 new file mode 100644 index 0000000..f4bdf7b --- /dev/null +++ b/backend/.env.bak-pre-appsecret-20260601 @@ -0,0 +1,14 @@ +NODE_ENV=production +PORT=3011 +POSTGRES_PASSWORD=d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046 +DATABASE_URL=postgresql://postgres:d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046@forgecrm-db:5432/postgres +POSTGRES_SSL=false +REDIS_URL=redis://redis:6379 +JWT_SECRET=86ac700ea5d601085e14cd900e8579a2fa2f0ec51656c9e9cb740c7c0a649e3c +FORGECRM_ENCRYPTION_KEY=2c756c2830654efdf72e5a8baf68ab34d1808191e90defbfeb5d93c3ff164fa3 +CORS_ORIGIN=https://wa.geekstudio.dev +META_API_VERSION=v21.0 +META_WEBHOOK_VERIFY_TOKEN=7419999ee702323c2d73e917826df514 +MEDIA_DIR=/app/media +ADMIN_EMAIL=admin@forgechat.local +ADMIN_PASSWORD=y3YNoh5lEQHVcPoD/5kk3XBP diff --git a/backend/.env.bak-pre-n8nurls-20260602T215557Z b/backend/.env.bak-pre-n8nurls-20260602T215557Z new file mode 100644 index 0000000..b3d0908 --- /dev/null +++ b/backend/.env.bak-pre-n8nurls-20260602T215557Z @@ -0,0 +1,19 @@ +NODE_ENV=production +PORT=3011 +POSTGRES_PASSWORD=d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046 +DATABASE_URL=postgresql://postgres:d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046@forgecrm-db:5432/postgres +POSTGRES_SSL=false +REDIS_URL=redis://redis:6379 +JWT_SECRET=86ac700ea5d601085e14cd900e8579a2fa2f0ec51656c9e9cb740c7c0a649e3c +FORGECRM_ENCRYPTION_KEY=2c756c2830654efdf72e5a8baf68ab34d1808191e90defbfeb5d93c3ff164fa3 +CORS_ORIGIN=https://wa.geekstudio.dev +META_API_VERSION=v21.0 +META_WEBHOOK_VERIFY_TOKEN=7419999ee702323c2d73e917826df514 +MEDIA_DIR=/app/media +ADMIN_EMAIL=admin@forgechat.local +ADMIN_PASSWORD=y3YNoh5lEQHVcPoD/5kk3XBP + +META_APP_SECRET=e22d1929f44a077a9ee17adb2a86a98e +N8N_IA360_HANDOFF_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/IA360WhatsAppHandoffDraft20260601/webhook%2520ia360%2520handoff/ia360-whatsapp-handoff +N8N_IA360_AVAILABILITY_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/IA360CalendarAvailability20260602/webhook%2520availability/ia360-calendar-availability +N8N_IA360_BOOK_WEBHOOK_URL=https://n8n.geekstudio.dev/webhook/IA360CalendarBook20260602/webhook%2520book/ia360-calendar-book diff --git a/backend/.env.bak-wa-20260601T165910Z b/backend/.env.bak-wa-20260601T165910Z new file mode 100644 index 0000000..28a57dd --- /dev/null +++ b/backend/.env.bak-wa-20260601T165910Z @@ -0,0 +1,14 @@ +NODE_ENV=production +PORT=3011 +POSTGRES_PASSWORD=d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046 +DATABASE_URL=postgresql://postgres:d7054997bd5b1b4ad88f80188e133be7880175cde9b6c046@forgecrm-db:5432/postgres +POSTGRES_SSL=false +REDIS_URL=redis://redis:6379 +JWT_SECRET=86ac700ea5d601085e14cd900e8579a2fa2f0ec51656c9e9cb740c7c0a649e3c +FORGECRM_ENCRYPTION_KEY=2c756c2830654efdf72e5a8baf68ab34d1808191e90defbfeb5d93c3ff164fa3 +CORS_ORIGIN=http://localhost:4017 +META_API_VERSION=v21.0 +META_WEBHOOK_VERIFY_TOKEN=7419999ee702323c2d73e917826df514 +MEDIA_DIR=/app/media +ADMIN_EMAIL=admin@forgechat.local +ADMIN_PASSWORD=y3YNoh5lEQHVcPoD/5kk3XBP diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..f4159b1 --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,98 @@ +# 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-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): ✅ 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. + +## 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/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/index.js.bak-b28-1780543839 b/backend/src/index.js.bak-b28-1780543839 new file mode 100644 index 0000000..40ebbfc --- /dev/null +++ b/backend/src/index.js.bak-b28-1780543839 @@ -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/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/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..33cfe80 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,38 @@ 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; + } + } + + // 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') { @@ -82,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/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-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/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/ia360DealRouting.js b/backend/src/routes/ia360DealRouting.js new file mode 100644 index 0000000..a241213 --- /dev/null +++ b/backend/src/routes/ia360DealRouting.js @@ -0,0 +1,136 @@ +// 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 = { + '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', +}; + +// 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; +} + +// ============================================================================ +// 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, + IA360_PARTNER_RELATIONSHIPS, + IA360_PARTNER_STAGE_MAP, + ia360PipelineForRelationship, + ia360ResolveStageName, + IA360_PARTNER_BLUEPRINT_ENABLED, + IA360_PARTNER_BLUEPRINT_SEQUENCES, + ia360PartnerBlueprintSequences, + ia360PartnerBlueprintSendable, +}; 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/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/templates.js b/backend/src/routes/templates.js index 9213823..ffa8c6c 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' }); @@ -809,14 +809,44 @@ 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 = 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}`, + 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 c65ca9f..9906767 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -5,6 +5,25 @@ 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 { classifyOpenerReply, extractInteractiveReplyId } = require('./ia360OpenerReply'); +const { evaluatePaymentStatus } = require('../services/paymentCircuitBreaker'); +const { + IA360_DEFAULT_PIPELINE_NAME, + IA360_PARTNERS_PIPELINE_NAME, + IA360_PARTNER_RELATIONSHIPS, + ia360PipelineForRelationship, + ia360ResolveStageName, +} = require('./ia360DealRouting'); +const { + parseIa360OwnerIntroCommand, + sanitizeIntroName: sanitizeIa360IntroNamePure, + compactQuienIntro: compactQuienIntroPure, + buildIntroCustomFields: buildIa360IntroCustomFields, + buildReferidoContextoDraft: buildIa360ReferidoContextoDraft, +} = require('./ia360ReferidoIntro'); const router = Router(); @@ -112,6 +131,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,11 +202,8962 @@ 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)] + ); +} + +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 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; + 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 || {}; + 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(); + // 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, + 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 ${jaulaWhere} + 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 ${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 }; +} + +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, + // 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) { + // 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. +// 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) { + return sanitizeIa360IntroNamePure(raw); +} + +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)); +} + +// 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 = $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, resolvedStageName] + ); + 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). + // 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', + 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 && requestedStageName === 'Requiere Alek' && existing.current_stage_name !== targetStage.name) { + 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 */ } + + // 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, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + 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, + 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, 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, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + 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, + 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, {{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) => { + 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) { + 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, vars) }); + 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', + // 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, + 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, + }, + }); + if (!localId) return { ok: false, status: 'duplicate', error: null }; // dedupe atómico ON CONFLICT + 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' }; + } +} + +// 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) { + return extractInteractiveReplyId(record); +} + + +// ── 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); + // 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', + 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, memory = 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. + // 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; + 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 }) => 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', + 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 { 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; + 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.`, + 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; + } + + // 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.`, + 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; + } + + // 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".`, + 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; + } + + // 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.`, + 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; + } + + // 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.`, + pipelineName: dealPipelineName, + }).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'] }); + // 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 || ''))) { + // 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, 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).`, + 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. + // 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.`); + } + } + + // 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(); + 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, + }); + // 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); + 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') { + // 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 con "intro ${name}: " (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" (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({ + 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.', + // 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, + 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; +} + +// 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 { + // 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({ + 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', ...(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) { + 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 { + // 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; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + // 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) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// ─── 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 +// 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 }); +} + +// ─── 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(() => {}); + } +} + +// 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 +// 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 +// 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). + // 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); + 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 || '')); + // 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), + buildIa360ConversationRoleHint({ contact: contactContext, stageName: deal.stage_name, convState: betaConvState, memory: betaMemory, messageBody: record.message_body }), + ].join(' | '), + memory: betaMemory, + }); + 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 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 || ''); + 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; + } + // 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. + // 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), + // 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 + ')' : ''}` }); + } + // 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. + // 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; + } + // 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; + } + 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; + } + + // ── 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 + // 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; + } + } + 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.', + '- `intro : ` para teclear la intro de un referido.' + ].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 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'; + 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; + } + 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 +// 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 @@ -212,6 +9185,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 }); @@ -227,9 +9216,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; } @@ -311,6 +9311,131 @@ 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. + 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 + } + } + // ── 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 + } + } + // ── 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 + // 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 + } + } + + // ── 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 + } + + // 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 @@ -328,9 +9453,40 @@ 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; }); + // 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)); + } + } } } } @@ -344,6 +9500,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); + } } } @@ -356,7 +9519,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 @@ -405,4 +9572,341 @@ 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']; + +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- 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/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/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/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..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 }) { +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, @@ -61,13 +78,19 @@ 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, 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/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/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/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); }); 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'\)/); +}); 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/backend/test/ia360NoSilenceRegression.test.js b/backend/test/ia360NoSilenceRegression.test.js new file mode 100644 index 0000000..e6747f7 --- /dev/null +++ b/backend/test/ia360NoSilenceRegression.test.js @@ -0,0 +1,120 @@ +// 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', () => { + // 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, '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;/, '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'); + // 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/); +}); + +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\}\$/); +}); + +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/); +}); + +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'); +}); 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'); +}); 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'/); +}); 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); +}); 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 0000000..8a83acf Binary files /dev/null and b/frontend/public/ia360-bca/alek_presente.jpg differ diff --git a/frontend/public/ia360-bca/bi_solucion.jpg b/frontend/public/ia360-bca/bi_solucion.jpg new file mode 100644 index 0000000..641432d Binary files /dev/null and b/frontend/public/ia360-bca/bi_solucion.jpg differ diff --git a/frontend/public/ia360-bca/dolor_ceo.jpg b/frontend/public/ia360-bca/dolor_ceo.jpg new file mode 100644 index 0000000..0d08652 Binary files /dev/null and b/frontend/public/ia360-bca/dolor_ceo.jpg differ diff --git a/frontend/public/ia360-bca/transformacion.jpg b/frontend/public/ia360-bca/transformacion.jpg new file mode 100644 index 0000000..2bef72f Binary files /dev/null and b/frontend/public/ia360-bca/transformacion.jpg differ 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 ? ( ) : (
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/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 diff --git a/gcold-e2e.sh b/gcold-e2e.sh new file mode 100644 index 0000000..22500a4 --- /dev/null +++ b/gcold-e2e.sh @@ -0,0 +1,286 @@ +#!/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 +} +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));}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)" + +# 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' 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' 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. +# 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" + +# 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)" + +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" + +# 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 "" +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") +# 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'") +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); }); 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" diff --git a/glive-e2e.sh b/glive-e2e.sh new file mode 100755 index 0000000..6f61746 --- /dev/null +++ b/glive-e2e.sh @@ -0,0 +1,132 @@ +#!/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" +# 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" +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 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/n8n-ia360-whatsapp-handoff-active.workflow.json b/n8n-ia360-whatsapp-handoff-active.workflow.json new file mode 100644 index 0000000..5ab50fe --- /dev/null +++ b/n8n-ia360-whatsapp-handoff-active.workflow.json @@ -0,0 +1,99 @@ +{ + "id": "IA360WhatsAppHandoffDraft20260601", + "name": "IA360 WhatsApp Handoff → EspoCRM Active", + "active": true, + "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 + ] + } + ], + "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 0000000..ae57774 Binary files /dev/null and b/out/ia360-media-tests/ia360-100m-dolor-ejecutivo-ceo-agobiado.jpg differ 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 0000000..a8b8e62 Binary files /dev/null and b/out/ia360-media-tests/ia360-100m-mecanismo-whatsapp-crm-bi.jpg differ diff --git a/out/persona-first-qa/forgechat-chats-persona-first-owner.png b/out/persona-first-qa/forgechat-chats-persona-first-owner.png new file mode 100644 index 0000000..3beb8aa Binary files /dev/null and b/out/persona-first-qa/forgechat-chats-persona-first-owner.png differ 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 0000000..a9e0b33 Binary files /dev/null and b/out/persona-first-qa/forgechat-owner-chat-persona-first-readouts.png differ 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 0000000..f3cf31a Binary files /dev/null and b/out/persona-first-qa/forgechat-public-owner-chat-aliado-guarded-readout.png differ diff --git a/out/persona-first-qa/forgechat-public-owner-chat-persona-first-readouts-authenticated.png b/out/persona-first-qa/forgechat-public-owner-chat-persona-first-readouts-authenticated.png new file mode 100644 index 0000000..77ac912 Binary files /dev/null and b/out/persona-first-qa/forgechat-public-owner-chat-persona-first-readouts-authenticated.png differ 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 0000000..9d12b0f Binary files /dev/null and b/out/persona-first-qa/forgechat-public-owner-chat-persona-first-readouts.png differ 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 0000000..7487603 Binary files /dev/null and b/out/persona-first-qa/forgechat-public-owner-chat-search-qa-personafirst.png differ 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 0000000..1f11434 Binary files /dev/null and b/out/persona-first-qa/forgechat-public-owner-chat-tecnico-guarded-readout.png differ 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() 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 ] 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); }); 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();