From d66e8118eb3652b836639ae4a178699540bb9cad Mon Sep 17 00:00:00 2001 From: Marco Gallegos Date: Sat, 20 Dec 2025 22:43:34 -0600 Subject: [PATCH] feat: refactor schedule storage to vanity_hr schema, update onboarding command to /registro, and enhance horario flow with short name collection --- Readme.md | 27 ++- Vanessa.md | 6 +- conv-flows/horario.json | 98 +++++------ conv-flows/leave_request.json | 34 ++-- conv-flows/onboarding.json | 262 ++++++++++++++++------------- conv-flows/vacations.json | 55 ++++-- db/init/init.sql | 39 ++--- db_logic.md | 34 ++-- main.py | 6 + models/vanity_attendance_models.py | 33 ---- models/vanity_hr_models.py | 14 +- modules/database.py | 4 +- modules/finalizer.py | 111 ++++++++---- modules/flow_builder.py | 241 +++++++++++++++++--------- modules/onboarding.py | 9 +- modules/ui.py | 2 +- 16 files changed, 570 insertions(+), 405 deletions(-) diff --git a/Readme.md b/Readme.md index 350f091..bd03228 100644 --- a/Readme.md +++ b/Readme.md @@ -10,11 +10,12 @@ Este repositorio está pensado como **proyecto Python profesional**, modular y l Vanessa no es un chatbot genérico: es una interfaz conversacional para procesos reales de negocio. -- **Onboarding completo de nuevas socias (`/welcome`)**: Recolecta datos, valida que no existan duplicados en la DB, y ejecuta un registro en dos fases: +- **Onboarding completo de nuevas socias (`/registro`, alias `/welcome`)**: Recolecta datos, valida que no existan duplicados en la DB, y ejecuta un registro en dos fases. **Debe completarse una vez para habilitar el resto de comandos.** 1. **Crea un usuario de acceso** en la tabla `USERS_ALMA.users` para la autenticación del bot. 2. **Crea un perfil de empleada** completo en la tabla `vanity_hr.data_empleadas`, que es la tabla maestra de RRHH. -- **Solicitud de vacaciones (`/vacaciones`)**: Flujo dinámico para gestionar días de descanso. -- **Solicitud de permisos por horas (`/permiso`)**: Incluye clasificación de motivos mediante IA (Gemini). +- **Definición de horario semanal (`/horario`)**: Captura guiada día a día que termina en un upsert por día dentro de `vanity_hr.horario_empleadas` y dispara un webhook operativo; sólo se habilita si ya estás registrada. +- **Solicitud de vacaciones (`/vacaciones`)**: Flujo dinámico para gestionar días de descanso, disponible sólo si tu `telegram_id` ya existe en la base vía `/registro`. +- **Solicitud de permisos por horas (`/permiso`)**: Incluye clasificación de motivos mediante IA (Gemini) y requiere que el onboarding haya terminado. Cada flujo es un módulo independiente que interactúa con la base de datos y flujos de **n8n**. @@ -38,11 +39,14 @@ vanity_bot/ │ ├── vanity_hr_models.py │ └── vanity_attendance_models.py │ -└── modules/ # Habilidades del bot +├── conv-flows/ # Plantillas JSON de flujos declarativos (p. ej. horario.json) +└── modules/ # Habilidades del bot y utilidades ├── ai.py # Clasificación de motivos con Gemini ├── database.py # Conexión a DB y lógica de negocio (registro/verificación) + ├── finalizer.py # Acciones finales por flujo (webhooks + persistencia) + ├── flow_builder.py # Loader que convierte las plantillas JSON en ConversationHandlers ├── logger.py # Registro de auditoría - ├── onboarding.py # Flujo /welcome + ├── onboarding.py # Flujo /registro (/welcome) ├── rh_requests.py # /vacaciones y /permiso └── ui.py # Teclados y componentes de interfaz ``` @@ -64,6 +68,7 @@ GOOGLE_API_KEY=AIzaSy... WEBHOOK_ONBOARDING=https://... WEBHOOK_VACACIONES=https://... WEBHOOK_PERMISOS=https://... +WEBHOOK_SCHEDULE=https://... # --- DATABASE SETUP --- MYSQL_HOST=db @@ -79,6 +84,18 @@ MYSQL_DATABASE_VANITY_ATTENDANCE=vanity_attendance --- +## 🔄 Flujos declarativos + +El bot puede registrar conversaciones complejas sin código específico gracias a: + +- `conv-flows/*.json`: Describe cada paso del flujo (texto, teclados, variables y transiciones). Actualmente `horario.json` define `/horario`. +- `modules/flow_builder.py`: Lee los JSON y crea dinámicamente los `ConversationHandler`. +- `modules/finalizer.py`: Ejecuta la acción final de cada flujo. Para `/horario` convierte las horas a formato 24 h, envía `WEBHOOK_SCHEDULE` y distribuye los registros por día en `vanity_hr.horario_empleadas`. + +Si un flujo requiere lógica adicional, se agrega un finalizer nuevo y se anota en el map `FINALIZATION_MAP`. + +--- + ## 🐳 Ejecución con Docker (Recomendado) El proyecto está dockerizado para facilitar su despliegue y aislamiento. diff --git a/Vanessa.md b/Vanessa.md index 1a78629..86c159c 100644 --- a/Vanessa.md +++ b/Vanessa.md @@ -56,7 +56,7 @@ Arquitectura **modular y desacoplada**: │ └── modules/ # HABILIDADES ├── __init__.py - ├── onboarding.py # /welcome — Contrato (35 pasos) + ├── onboarding.py # /registro (/welcome) — Contrato (35 pasos) ├── rh_requests.py # /vacaciones y /permiso (IA) ``` @@ -64,7 +64,7 @@ Arquitectura **modular y desacoplada**: ## 💬 Módulos y Flujos Conversacionales -### 1️⃣ Onboarding — `/welcome` +### 1️⃣ Onboarding — `/registro` (alias `/welcome`) **Objetivo** Recopilar la información completa para el contrato de nuevas socias. @@ -80,7 +80,7 @@ Recopilar la información completa para el contrato de nuevas socias. **Ejemplo de conversación** ``` -User: /welcome +User: /registro Vanessa: ¡Hola Ana! 👋 Soy Vanessa de RH. Vamos a dejar listo tu registro. Vanessa: ¿Cómo te gusta que te llamemos? User: Anita diff --git a/conv-flows/horario.json b/conv-flows/horario.json index 1361616..3ab4fac 100644 --- a/conv-flows/horario.json +++ b/conv-flows/horario.json @@ -2,114 +2,118 @@ "flow_name": "horario", "steps": [ { - "state": 0, + "state": -3, "variable": "INTRO_READ", "type": "keyboard", - "question": "ANTES DE DEFINIR TUS HORARIOS\n\nLee con atención:\n\n• Estos horarios se usarán para control de asistencia y reportes\n• Selecciona únicamente los botones disponibles\n• El sistema convertirá automáticamente a formato 24 hrs\n\nCuando estés listo, continúa.", - "options": ["Continuar"], - "next_step": 1 + "question": "ANTES DE DEFINIR TUS HORARIOS\n\nLee con atención:\n\n• Estos horarios se usarán para control de asistencia y reportes\n• Selecciona únicamente los botones disponibles\n• El sistema convertirá automáticamente a formato 24 hrs\n\nCuando estés lista, confirma para continuar.", + "options": ["Continuar"] + }, + { + "state": -2, + "variable": "INTRO_NAME", + "type": "info", + "question": "NOMBRE CORTO\n\nEste nombre se usará para mensajes internos, reportes y notificaciones." + }, + { + "state": -1, + "variable": "SHORT_NAME", + "type": "text", + "question": "¿Cómo te dicen normalmente?" + }, + { + "state": 0, + "variable": "INTRO_HOURS", + "type": "info", + "question": "DEFINICIÓN DE HORARIOS\n\nDefine tu horario habitual por día usando botones AM / PM." }, { "state": 1, - "variable": "SHORT_NAME", - "type": "text", - "question": "¿Cómo te dicen normalmente?", - "next_step": 2 - }, - { - "state": 2, "variable": "MONDAY_IN", "type": "keyboard", "question": "Lunes · Hora de entrada", - "options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], - "next_step": 3 + "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"] }, { - "state": 3, + "state": 2, "variable": "MONDAY_OUT", "type": "keyboard", "question": "Lunes · Hora de salida", - "options": ["4:00 PM","5:00 PM","6:00 PM","7:00 PM","8:00 PM"], - "next_step": 4 + "options": ["4:00 PM", "5:00 PM", "6:00 PM", "7:00 PM", "8:00 PM"] }, { - "state": 4, + "state": 3, "variable": "TUESDAY_IN", "type": "keyboard", "question": "Martes · Hora de entrada", - "options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], - "next_step": 5 + "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"] }, { - "state": 5, + "state": 4, "variable": "TUESDAY_OUT", "type": "keyboard", "question": "Martes · Hora de salida", - "options": ["4:00 PM","5:00 PM","6:00 PM","7:00 PM","8:00 PM"], - "next_step": 6 + "options": ["4:00 PM", "5:00 PM", "6:00 PM", "7:00 PM", "8:00 PM"] }, { - "state": 6, + "state": 5, "variable": "WEDNESDAY_IN", "type": "keyboard", "question": "Miércoles · Hora de entrada", - "options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], - "next_step": 7 + "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"] }, { - "state": 7, + "state": 6, "variable": "WEDNESDAY_OUT", "type": "keyboard", "question": "Miércoles · Hora de salida", - "options": ["4:00 PM","5:00 PM","6:00 PM","7:00 PM","8:00 PM"], - "next_step": 8 + "options": ["4:00 PM", "5:00 PM", "6:00 PM", "7:00 PM", "8:00 PM"] }, { - "state": 8, + "state": 7, "variable": "THURSDAY_IN", "type": "keyboard", "question": "Jueves · Hora de entrada", - "options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], - "next_step": 9 + "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"] }, { - "state": 9, + "state": 8, "variable": "THURSDAY_OUT", "type": "keyboard", "question": "Jueves · Hora de salida", - "options": ["4:00 PM","5:00 PM","6:00 PM","7:00 PM","8:00 PM"], - "next_step": 10 + "options": ["4:00 PM", "5:00 PM", "6:00 PM", "7:00 PM", "8:00 PM"] }, { - "state": 10, + "state": 9, "variable": "FRIDAY_IN", "type": "keyboard", "question": "Viernes · Hora de entrada", - "options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], - "next_step": 11 + "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"] }, { - "state": 11, + "state": 10, "variable": "FRIDAY_OUT", "type": "keyboard", "question": "Viernes · Hora de salida", - "options": ["4:00 PM","5:00 PM","6:00 PM","7:00 PM","8:00 PM"], - "next_step": 12 + "options": ["4:00 PM", "5:00 PM", "6:00 PM", "7:00 PM", "8:00 PM"] }, { - "state": 12, + "state": 11, "variable": "SATURDAY_IN", "type": "keyboard", "question": "Sábado · Hora de entrada", - "options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], - "next_step": 13 + "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"] }, { - "state": 13, + "state": 12, "variable": "SATURDAY_OUT", "type": "info", - "question": "Sábado · Hora de salida\n\nLa salida del sábado queda registrada automáticamente a las 6:00 PM.", - "next_step": -1 + "question": "Sábado · Hora de salida\n\nLa salida del sábado queda registrada automáticamente a las 6:00 PM." + }, + { + "state": 99, + "variable": "FLOW_END", + "type": "info", + "question": "HORARIOS REGISTRADOS\n\nTus horarios quedaron guardados correctamente. Si necesitas un ajuste, notifícalo a administración." } ] -} \ No newline at end of file +} diff --git a/conv-flows/leave_request.json b/conv-flows/leave_request.json index 38251e1..04cc559 100644 --- a/conv-flows/leave_request.json +++ b/conv-flows/leave_request.json @@ -1,10 +1,22 @@ { "flow_name": "leave_request", "steps": [ + { + "state": "INTRO_ALERT", + "variable": "AVISO_INICIAL", + "question": "⏱️ SOLICITUD DE PERMISO POR HORAS\n\nConfirma que le avisaste a tu manager y que necesitas registrar un permiso formal.", + "type": "keyboard", + "options": ["Continuar"] + }, + { + "state": "INTRO_SCOPE", + "variable": "INTRO_SCOPE", + "question": "SECCIÓN ÚNICA · FECHAS, HORARIO Y MOTIVO\n\nResponde exactamente lo que se te pide para evitar rechazos.", + "type": "info" + }, { "state": "PERMISO_CUANDO", - "question": "⏱️ **Solicitud de Permiso**\n\n¿Para cuándo lo necesitas?", - "handler": "recibir_cuando_permiso", + "question": "¿Para cuándo lo necesitas?", "type": "keyboard", "options": ["Hoy", "Mañana", "Pasado mañana", "Fecha específica"], "next_step": [ @@ -21,7 +33,6 @@ { "state": "PERMISO_ANIO", "question": "¿Para qué año es el permiso? (elige el actual o el siguiente)", - "handler": "recibir_anio_permiso", "type": "keyboard", "options": ["current_year", "next_year"], "next_step": "INICIO_DIA" @@ -29,14 +40,12 @@ { "state": "INICIO_DIA", "question": "¿En qué *día* inicia el permiso? (número, ej: 12)", - "handler": "recibir_inicio_dia", "type": "text", "next_step": "INICIO_MES" }, { "state": "INICIO_MES", "question": "¿De qué *mes* inicia?", - "handler": "recibir_inicio_mes", "type": "keyboard", "options": [ "Enero", "Febrero", "Marzo", @@ -49,14 +58,12 @@ { "state": "FIN_DIA", "question": "¿Qué *día* termina?", - "handler": "recibir_fin_dia", "type": "text", "next_step": "FIN_MES" }, { "state": "FIN_MES", "question": "¿De qué *mes* termina?", - "handler": "recibir_fin_mes", "type": "keyboard", "options": [ "Enero", "Febrero", "Marzo", @@ -69,15 +76,20 @@ { "state": "HORARIO", "question": "¿Cuál es el horario? Ej: `09:00-11:00` o `Todo el día`.", - "handler": "recibir_horario", "type": "text", "next_step": "MOTIVO" }, { "state": "MOTIVO", "question": "Entendido. ¿Cuál es el motivo o comentario adicional?", - "handler": "recibir_motivo_fin", - "type": "text" + "type": "text", + "next_step": "FLOW_END" + }, + { + "state": "FLOW_END", + "variable": "FLOW_END", + "question": "✅ Gracias. Tu permiso quedó registrado y RH revisará la información. Si necesitas otro, vuelve a iniciar con /permiso.", + "type": "info" } ] -} \ No newline at end of file +} diff --git a/conv-flows/onboarding.json b/conv-flows/onboarding.json index 719672f..4fe96d8 100644 --- a/conv-flows/onboarding.json +++ b/conv-flows/onboarding.json @@ -1,55 +1,67 @@ { "flow_name": "onboarding", "steps": [ + { + "state": -2, + "variable": "AVISO_INICIAL", + "question": "⚠️ AVISO IMPORTANTE\n\nEste formulario crea tu expediente oficial.\n\n• Lee cada pregunta con atención.\n• Responde solo lo que se te pide.\n• Escribe /cancelar en cualquier momento para detener el proceso.\n\nConfirma cuando estés lista.", + "type": "keyboard", + "options": ["Comenzar"] + }, + { + "state": -1, + "variable": "INTRO_PERSONALES", + "question": "SECCIÓN 1 · DATOS PERSONALES\n\nUsaremos tu información exactamente como aparece en tus documentos oficiales.\n\n(Confirma para continuar)", + "type": "keyboard", + "options": ["Continuar"] + }, { "state": 0, "variable": "NOMBRE_SALUDO", "question": "¿Cómo te gusta que te llamemos?", - "type": "text", - "next_step": 1 + "type": "text" }, { "state": 1, "variable": "NOMBRE_COMPLETO", "question": "Escribe tus nombres (SIN apellidos), exactamente como aparecen en tu INE.", - "type": "text", - "next_step": 2 + "type": "text" }, { "state": 2, "variable": "APELLIDO_PATERNO", "question": "Apellido paterno:", - "type": "text", - "next_step": 3 + "type": "text" }, { "state": 3, "variable": "APELLIDO_MATERNO", "question": "Apellido materno:", - "type": "text", - "next_step": 4 + "type": "text" }, { "state": 4, "variable": "CUMPLE_DIA", "question": "Fecha de nacimiento · Día (solo número, ej. 13)", - "type": "text", - "next_step": 5 + "type": "text" }, { "state": 5, "variable": "CUMPLE_MES", "question": "Fecha de nacimiento · Mes", "type": "keyboard", - "options": ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"], - "next_step": 6 + "options": [ + "Enero", "Febrero", "Marzo", + "Abril", "Mayo", "Junio", + "Julio", "Agosto", "Septiembre", + "Octubre", "Noviembre", "Diciembre" + ] }, { "state": 6, "variable": "CUMPLE_ANIO", "question": "Fecha de nacimiento · Año (4 dígitos)", - "type": "text", - "next_step": 7 + "type": "text" }, { "state": 7, @@ -59,7 +71,7 @@ "options": ["Coahuila", "Nuevo León", "Otro"], "next_steps": [ { "value": "Otro", "go_to": 7.1 }, - { "value": "default", "go_to": 8 } + { "value": "default", "go_to": 7.5 } ] }, { @@ -67,70 +79,68 @@ "variable": "ESTADO_NACIMIENTO_OTRO", "question": "Escribe el nombre del estado donde naciste.", "type": "text", - "next_step": 8 + "next_step": 7.5 + }, + { + "state": 7.5, + "variable": "INTRO_ADMIN", + "question": "SECCIÓN 2 · DATOS ADMINISTRATIVOS\n\nEstos datos se usan para contrato, nómina y comunicación oficial.\n\n(Confirma para continuar)", + "type": "keyboard", + "options": ["Continuar"] }, { "state": 8, "variable": "RFC", "question": "RFC completo (13 caracteres, sin espacios):", - "type": "text", - "next_step": 9 + "type": "text" }, { "state": 9, "variable": "CURP", "question": "CURP completo (18 caracteres):", - "type": "text", - "next_step": 10 + "type": "text" }, { "state": 10, "variable": "CORREO", "question": "Correo electrónico personal:", - "type": "text", - "next_step": 11 + "type": "text" }, { "state": 11, "variable": "CELULAR", "question": "Número de celular (10 dígitos):", - "type": "text", - "next_step": 12 + "type": "text" }, { "state": 12, "variable": "CALLE", "question": "Domicilio · Calle:", - "type": "text", - "next_step": 13 + "type": "text" }, { "state": 13, "variable": "NUM_EXTERIOR", "question": "Domicilio · Número exterior:", - "type": "text", - "next_step": 14 + "type": "text" }, { "state": 14, "variable": "NUM_INTERIOR", "question": "Domicilio · Número interior (0 si no aplica):", - "type": "text", - "next_step": 15 + "type": "text" }, { "state": 15, "variable": "COLONIA", "question": "Domicilio · Colonia:", - "type": "text", - "next_step": 16 + "type": "text" }, { "state": 16, "variable": "CODIGO_POSTAL", "question": "Código Postal (5 dígitos):", - "type": "text", - "next_step": 17 + "type": "text" }, { "state": 17, @@ -138,7 +148,7 @@ "question": "Ciudad de residencia:", "type": "keyboard", "options": ["Saltillo", "Ramos Arizpe", "Arteaga", "Otro"], - "next_steps": [ + "next_steps": [ { "value": "Otro", "go_to": 17.1 }, { "value": "default", "go_to": 18 } ] @@ -155,167 +165,179 @@ "variable": "ROL", "question": "Rol dentro del equipo:", "type": "keyboard", - "options": ["Belleza", "Staff (Recepción)", "Marketing"], - "next_step": 19 + "options": ["Belleza", "Staff (Recepción)", "Marketing"] }, { "state": 19, "variable": "SUCURSAL", "question": "Sucursal principal:", "type": "keyboard", - "options": ["Plaza Cima (Sur)", "Plaza O (Carranza)"], - "next_step": 20 + "options": ["Plaza Cima (Sur)", "Plaza O (Carranza)"] }, { "state": 20, "variable": "INICIO_DIA", "question": "Fecha de ingreso · Día:", - "type": "text", - "next_step": 21 + "type": "text" }, { "state": 21, "variable": "INICIO_MES", "question": "Fecha de ingreso · Mes:", "type": "keyboard", - "options": ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"], - "next_step": 22 + "options": [ + "Enero", "Febrero", "Marzo", + "Abril", "Mayo", "Junio", + "Julio", "Agosto", "Septiembre", + "Octubre", "Noviembre", "Diciembre" + ] }, { "state": 22, "variable": "INICIO_ANIO", "question": "Fecha de ingreso · Año:", "type": "keyboard", - "options": ["2024", "2025", "2026"], - "next_step": 23 + "options": ["2024", "2025", "2026"] + }, + { + "state": 22.5, + "variable": "INTRO_EMERGENCIA", + "question": "SECCIÓN 3 · CONTACTO DE EMERGENCIA\n\nEsta persona solo será contactada en una emergencia real.\n\n(Confirma para continuar)", + "type": "keyboard", + "options": ["Continuar"] }, { "state": 23, - "variable": "REF1_NOMBRE", - "question": "Referencia 1 · Nombre completo:", - "type": "text", - "next_step": 24 + "variable": "EMERGENCIA_NOMBRE", + "question": "Contacto de emergencia · Nombre completo:", + "type": "text" }, { "state": 24, - "variable": "REF1_TELEFONO", - "question": "Referencia 1 · Teléfono:", - "type": "text", - "next_step": 25 + "variable": "EMERGENCIA_TEL", + "question": "Contacto de emergencia · Teléfono:", + "type": "text" }, { "state": 25, - "variable": "REF1_TIPO", - "question": "Referencia 1 · Relación:", + "variable": "EMERGENCIA_RELACION", + "question": "Relación con el contacto de emergencia:", "type": "keyboard", - "options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"], + "options": ["Padre/Madre", "Pareja", "Hermano/a", "Hijo/a", "Amigo/a", "Otro"], "next_steps": [ - { "value": "Otra", "go_to": 25.1 }, + { "value": "Otro", "go_to": 25.1 }, { "value": "default", "go_to": 26 } ] }, { "state": 25.1, - "variable": "REF1_TIPO_OTRA", - "question": "Especifíca la relación con la Referencia 1:", + "variable": "EMERGENCIA_RELACION_OTRA", + "question": "Describe la relación con tu contacto de emergencia:", "type": "text", "next_step": 26 }, { "state": 26, - "variable": "REF2_NOMBRE", - "question": "Referencia 2 · Nombre completo:", - "type": "text", - "next_step": 27 + "variable": "INTRO_REFERENCIAS", + "question": "SECCIÓN 4 · REFERENCIAS PERSONALES\n\nNecesitamos 3 referencias reales que te conozcan.\n\n(Confirma para continuar)", + "type": "keyboard", + "options": ["Continuar"] }, { "state": 27, - "variable": "REF2_TELEFONO", - "question": "Referencia 2 · Teléfono:", - "type": "text", - "next_step": 28 + "variable": "REF1_NOMBRE", + "question": "Referencia 1 · Nombre completo:", + "type": "text" }, { "state": 28, - "variable": "REF2_TIPO", - "question": "Referencia 2 · Relación:", - "type": "keyboard", - "options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"], - "next_steps": [ - { "value": "Otra", "go_to": 28.1 }, - { "value": "default", "go_to": 29 } - ] - }, - { - "state": 28.1, - "variable": "REF2_TIPO_OTRA", - "question": "Especifíca la relación con la Referencia 2:", - "type": "text", - "next_step": 29 + "variable": "REF1_TELEFONO", + "question": "Referencia 1 · Teléfono:", + "type": "text" }, { "state": 29, - "variable": "REF3_NOMBRE", - "question": "Referencia 3 · Nombre completo:", + "variable": "REF1_TIPO", + "question": "Referencia 1 · Relación:", + "type": "keyboard", + "options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"], + "next_steps": [ + { "value": "Otra", "go_to": 29.1 }, + { "value": "default", "go_to": 30 } + ] + }, + { + "state": 29.1, + "variable": "REF1_TIPO_OTRA", + "question": "Especifica la relación con la Referencia 1:", "type": "text", "next_step": 30 }, { "state": 30, - "variable": "REF3_TELEFONO", - "question": "Referencia 3 · Teléfono:", - "type": "text", - "next_step": 31 + "variable": "REF2_NOMBRE", + "question": "Referencia 2 · Nombre completo:", + "type": "text" }, { "state": 31, - "variable": "REF3_TIPO", - "question": "Referencia 3 · Relación:", - "type": "keyboard", - "options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"], - "next_steps": [ - { "value": "Otra", "go_to": 31.1 }, - { "value": "default", "go_to": 32 } - ] - }, - { - "state": 31.1, - "variable": "REF3_TIPO_OTRA", - "question": "Especifíca la relación con la Referencia 3:", - "type": "text", - "next_step": 32 + "variable": "REF2_TELEFONO", + "question": "Referencia 2 · Teléfono:", + "type": "text" }, { "state": 32, - "variable": "EMERGENCIA_NOMBRE", - "question": "Contacto de emergencia · Nombre completo:", + "variable": "REF2_TIPO", + "question": "Referencia 2 · Relación:", + "type": "keyboard", + "options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"], + "next_steps": [ + { "value": "Otra", "go_to": 32.1 }, + { "value": "default", "go_to": 33 } + ] + }, + { + "state": 32.1, + "variable": "REF2_TIPO_OTRA", + "question": "Especifica la relación con la Referencia 2:", "type": "text", "next_step": 33 }, { "state": 33, - "variable": "EMERGENCIA_TEL", - "question": "Contacto de emergencia · Teléfono:", - "type": "text", - "next_step": 34 + "variable": "REF3_NOMBRE", + "question": "Referencia 3 · Nombre completo:", + "type": "text" }, { "state": 34, - "variable": "EMERGENCIA_RELACION", - "question": "Relación con el contacto de emergencia:", + "variable": "REF3_TELEFONO", + "question": "Referencia 3 · Teléfono:", + "type": "text" + }, + { + "state": 35, + "variable": "REF3_TIPO", + "question": "Referencia 3 · Relación:", "type": "keyboard", - "options": ["Padre/Madre", "Esposo/a", "Pareja", "Hijo/a", "Hermano/a", "Amigo/a", "Otro"], + "options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"], "next_steps": [ - { "value": "Otro", "go_to": 34.1 }, - { "value": "default", "go_to": -1 } + { "value": "Otra", "go_to": 35.1 }, + { "value": "default", "go_to": 99 } ] }, { - "state": 34.1, - "variable": "EMERGENCIA_RELACION_OTRA", - "question": "Especifíca la relación con el contacto de emergencia:", - "type": "text", - "next_step": -1 + "state": 35.1, + "variable": "REF3_TIPO_OTRA", + "question": "Especifica la relación con la Referencia 3:", + "type": "text", + "next_step": 99 + }, + { + "state": 99, + "variable": "FLOW_END", + "question": "✅ ¡Gracias!\n\nTu registro quedó completo. RH validará la información y te confirmará los siguientes pasos.\n\nSi necesitas corregir algo, escribe /cancelar y empieza de nuevo.", + "type": "info" } ] -} \ No newline at end of file +} diff --git a/conv-flows/vacations.json b/conv-flows/vacations.json index e6d7cce..4c5d914 100644 --- a/conv-flows/vacations.json +++ b/conv-flows/vacations.json @@ -2,15 +2,28 @@ "flow_name": "vacations", "steps": [ { - "state": "INICIO_DIA", - "question": "🌴 **Solicitud de Vacaciones**\n\nVamos a registrar tu descanso. ¿Qué *día* inicia? (número, ej: 10)", - "handler": "recibir_inicio_dia", + "state": -2, + "variable": "AVISO_GENERAL", + "question": "🌴 SOLICITUD DE VACACIONES\n\nAntes de registrar tus fechas confirma que:\n\n• Ya hablaste con tu manager\n• Las fechas no se traslapan con guardias críticas\n• Sabes que el bot notificará al equipo de RH\n\nConfirma para continuar.", + "type": "keyboard", + "options": ["Continuar"] + }, + { + "state": -1, + "variable": "INTRO_FECHAS", + "question": "SECCIÓN ÚNICA · FECHAS Y MOTIVO\n\nComparte las fechas exactas del descanso y cualquier comentario relevante.", + "type": "info" + }, + { + "state": 0, + "variable": "INICIO_DIA", + "question": "¿Qué *día* inicia tu descanso? (número, ej: 10)", "type": "text" }, { - "state": "INICIO_MES", + "state": 1, + "variable": "INICIO_MES", "question": "¿De qué *mes* inicia?", - "handler": "recibir_inicio_mes", "type": "keyboard", "options": [ "Enero", "Febrero", "Marzo", @@ -20,22 +33,22 @@ ] }, { - "state": "INICIO_ANIO", - "question": "¿De qué *año* inicia?", - "handler": "recibir_inicio_anio", + "state": 2, + "variable": "INICIO_ANIO", + "question": "¿De qué *año* inicia? (elige el actual o el siguiente)", "type": "keyboard", "options": ["current_year", "next_year"] }, { - "state": "FIN_DIA", + "state": 3, + "variable": "FIN_DIA", "question": "¿Qué *día* termina tu descanso?", - "handler": "recibir_fin_dia", "type": "text" }, { - "state": "FIN_MES", + "state": 4, + "variable": "FIN_MES", "question": "¿De qué *mes* termina?", - "handler": "recibir_fin_mes", "type": "keyboard", "options": [ "Enero", "Febrero", "Marzo", @@ -45,17 +58,23 @@ ] }, { - "state": "FIN_ANIO", - "question": "¿De qué *año* termina tu descanso?", - "handler": "recibir_fin_anio", + "state": 5, + "variable": "FIN_ANIO", + "question": "¿De qué *año* termina tu descanso? (elige el actual o el siguiente)", "type": "keyboard", "options": ["current_year", "next_year"] }, { - "state": "MOTIVO", + "state": 6, + "variable": "MOTIVO", "question": "Entendido. ¿Cuál es el motivo o comentario adicional?", - "handler": "recibir_motivo_fin", "type": "text" + }, + { + "state": 99, + "variable": "FLOW_END", + "question": "✅ Solicitud registrada.\n\nRH validará la información y te confirmará si las fechas quedan asignadas. Si necesitas cambiar algo, vuelve a iniciar con /vacaciones.", + "type": "info" } ] -} \ No newline at end of file +} diff --git a/db/init/init.sql b/db/init/init.sql index 7e4e720..d499421 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -106,6 +106,16 @@ CREATE TABLE IF NOT EXISTS permisos ( FOREIGN KEY (numero_empleado) REFERENCES data_empleadas(numero_empleado) ); +CREATE TABLE IF NOT EXISTS horario_empleadas ( + id_horario INT AUTO_INCREMENT PRIMARY KEY, + numero_empleado VARCHAR(15), + telegram_id BIGINT, + dia_semana VARCHAR(20), + hora_entrada_teorica TIME, + hora_salida_teorica TIME, + FOREIGN KEY (numero_empleado) REFERENCES data_empleadas(numero_empleado) +); + USE vanity_attendance; CREATE TABLE IF NOT EXISTS asistencia_registros ( @@ -120,32 +130,3 @@ CREATE TABLE IF NOT EXISTS asistencia_registros ( telegram_id_usado BIGINT, FOREIGN KEY (numero_empleado) REFERENCES vanity_hr.data_empleadas(numero_empleado) ); - -CREATE TABLE IF NOT EXISTS horario_empleadas ( - id_horario INT AUTO_INCREMENT PRIMARY KEY, - numero_empleado VARCHAR(15), - telegram_id BIGINT, - dia_semana VARCHAR(20), - hora_entrada_teorica TIME, - hora_salida_teorica TIME, - FOREIGN KEY (numero_empleado) REFERENCES vanity_hr.data_empleadas(numero_empleado) -); - -CREATE TABLE IF NOT EXISTS horarios_configurados ( - id INT AUTO_INCREMENT PRIMARY KEY, - telegram_id BIGINT NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - short_name VARCHAR(100), - monday_in TIME, - monday_out TIME, - tuesday_in TIME, - tuesday_out TIME, - wednesday_in TIME, - wednesday_out TIME, - thursday_in TIME, - thursday_out TIME, - friday_in TIME, - friday_out TIME, - saturday_in TIME, - saturday_out TIME -); diff --git a/db_logic.md b/db_logic.md index e786498..1f9ce17 100644 --- a/db_logic.md +++ b/db_logic.md @@ -108,6 +108,20 @@ Tabla central de Recursos Humanos. Contiene información contractual, personal, | con_goce_sueldo | tinyint(1) | | 0 / 1 | | afecta_nomina | tinyint(1) | | Impacto | + +#### Tabla: `horario_empleadas` (Diccionario de turnos) + +| Campo | Tipo | Key | Descripción | +| -------------------- | ----------- | --- | ---------------- | +| id_horario | int | PRI | ID | +| numero_empleado | varchar(15) | MUL | Relación RH | +| telegram_id | bigint | | Llave webhook | +| dia_semana | varchar(20) | | monday, tuesday… | +| hora_entrada_teorica | time | | Entrada | +| hora_salida_teorica | time | | Salida | + +*Los capturados desde `/horario` generan un registro por día mediante upsert (por `telegram_id` + `dia_semana`).* + --- ### 2.2 Base de Datos: `vanity_attendance` @@ -128,19 +142,6 @@ Tabla central de Recursos Humanos. Contiene información contractual, personal, --- -#### Tabla: `horario_empleadas` (Diccionario de turnos) - -| Campo | Tipo | Key | Descripción | -| -------------------- | ----------- | --- | ---------------- | -| id_horario | int | PRI | ID | -| numero_empleado | varchar(15) | MUL | Relación RH | -| telegram_id | bigint | | Llave webhook | -| dia_semana | varchar(20) | | monday, tuesday… | -| hora_entrada_teorica | time | | Entrada | -| hora_salida_teorica | time | | Salida | - ---- - ### 2.3 Base de Datos: `USERS_ALMA` #### Tabla: `users` (10 campos) @@ -171,7 +172,7 @@ Tabla central de Recursos Humanos. Contiene información contractual, personal, ### 3.2 Asistencia * Identificación por `telegram_id` -* Cruce con `horario_empleadas` según día +* Cruce con `vanity_hr.horario_empleadas` según día * Cálculo de retraso contra horario teórico --- @@ -180,6 +181,7 @@ Tabla central de Recursos Humanos. Contiene información contractual, personal, * **Identificación**: `body.telegram.user_id` * **Operación**: Upsert por día +* **Persistencia**: `vanity_hr.horario_empleadas` (clave `telegram_id` + `dia_semana`) * **Formato**: conversión de `10:00 AM` → `10:00:00` --- @@ -199,7 +201,7 @@ LIMIT 1; -- Horario del día SELECT hora_entrada_teorica -FROM vanity_attendance.horario_empleadas +FROM vanity_hr.horario_empleadas WHERE telegram_id = ? AND dia_semana = 'monday'; ``` @@ -215,4 +217,4 @@ Este documento define el **contrato técnico** del sistema Vanity. Cualquier cam 2. **Modify `docker-compose.yml`:** Mount the initialization script. 3. **Update `.env.example`:** Add new environment variables. 4. **Implement SQLAlchemy models:** Create Python classes for each table. -5. **Refactor database logic:** Use the new models in the application. \ No newline at end of file +5. **Refactor database logic:** Use the new models in the application. diff --git a/main.py b/main.py index 52a3a09..a3f601f 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,8 @@ from modules.flow_builder import load_flows from modules.logger import log_request from modules.database import chat_id_exists # Importar chat_id_exists from modules.ui import main_actions_keyboard +from modules.onboarding import onboarding_handler +from modules.rh_requests import vacaciones_handler, permiso_handler # from modules.finder import finder_handler (Si lo creas después) # Cargar links desde variables de entorno @@ -117,6 +119,10 @@ def main(): flow_handlers = load_flows() for handler in flow_handlers: app.add_handler(handler) + + app.add_handler(onboarding_handler) + app.add_handler(vacaciones_handler) + app.add_handler(permiso_handler) app.add_handler(CommandHandler("links", links_menu)) # app.add_handler(finder_handler) diff --git a/models/vanity_attendance_models.py b/models/vanity_attendance_models.py index 9fca8a6..fc6f27c 100644 --- a/models/vanity_attendance_models.py +++ b/models/vanity_attendance_models.py @@ -18,36 +18,3 @@ class AsistenciaRegistros(Base): sucursal_registro = Column(String(50)) telegram_id_usado = Column(BigInteger) empleada = relationship("DataEmpleadas", backref="asistencia_registros") - -class HorarioEmpleadas(Base): - __tablename__ = 'horario_empleadas' - __table_args__ = {'schema': 'vanity_attendance'} - - id_horario = Column(Integer, primary_key=True, autoincrement=True) - numero_empleado = Column(String(15), ForeignKey('vanity_hr.data_empleadas.numero_empleado')) - telegram_id = Column(BigInteger) - dia_semana = Column(String(20)) - hora_entrada_teorica = Column(Time) - hora_salida_teorica = Column(Time) - empleada = relationship("DataEmpleadas", backref="horario_empleadas") - -class HorariosConfigurados(Base): - __tablename__ = 'horarios_configurados' - __table_args__ = {'schema': 'vanity_attendance'} - - id = Column(Integer, primary_key=True, autoincrement=True) - telegram_id = Column(BigInteger, nullable=False) - timestamp = Column(Time) - short_name = Column(String(100)) - monday_in = Column(Time) - monday_out = Column(Time) - tuesday_in = Column(Time) - tuesday_out = Column(Time) - wednesday_in = Column(Time) - wednesday_out = Column(Time) - thursday_in = Column(Time) - thursday_out = Column(Time) - friday_in = Column(Time) - friday_out = Column(Time) - saturday_in = Column(Time) - saturday_out = Column(Time) diff --git a/models/vanity_hr_models.py b/models/vanity_hr_models.py index 2f4b445..8ad6c5f 100644 --- a/models/vanity_hr_models.py +++ b/models/vanity_hr_models.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine, Column, Integer, String, Enum, TIMESTAMP, Date, Text, BigInteger, DateTime +from sqlalchemy import create_engine, Column, Integer, String, Enum, TIMESTAMP, Date, Text, BigInteger, DateTime, Time from sqlalchemy.dialects.mysql import TINYINT from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.sql import func @@ -90,3 +90,15 @@ class Permisos(Base): con_goce_sueldo = Column(TINYINT) afecta_nomina = Column(TINYINT) empleada = relationship("DataEmpleadas") + +class HorarioEmpleadas(Base): + __tablename__ = 'horario_empleadas' + __table_args__ = {'schema': 'vanity_hr'} + + id_horario = Column(Integer, primary_key=True, autoincrement=True) + numero_empleado = Column(String(15), ForeignKey('vanity_hr.data_empleadas.numero_empleado')) + telegram_id = Column(BigInteger) + dia_semana = Column(String(20)) + hora_entrada_teorica = Column(Time) + hora_salida_teorica = Column(Time) + empleada = relationship("DataEmpleadas", backref="horarios_registrados") diff --git a/modules/database.py b/modules/database.py index ee78535..a1c5089 100644 --- a/modules/database.py +++ b/modules/database.py @@ -4,8 +4,8 @@ from datetime import datetime, date from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from models.users_alma_models import Base as BaseUsersAlma, User -from models.vanity_hr_models import Base as BaseVanityHr, DataEmpleadas, Vacaciones, Permisos -from models.vanity_attendance_models import Base as BaseVanityAttendance, AsistenciaRegistros, HorarioEmpleadas +from models.vanity_hr_models import Base as BaseVanityHr, DataEmpleadas, Vacaciones, Permisos, HorarioEmpleadas +from models.vanity_attendance_models import Base as BaseVanityAttendance, AsistenciaRegistros # --- DATABASE (MySQL) SETUP --- diff --git a/modules/finalizer.py b/modules/finalizer.py index affcb8a..0cef8dd 100644 --- a/modules/finalizer.py +++ b/modules/finalizer.py @@ -2,10 +2,10 @@ import os import json import logging import requests -from datetime import datetime -from sqlalchemy.orm import sessionmaker -from modules.database import get_engine -from models.vanity_attendance_models import HorariosConfigurados +from datetime import datetime, time as time_cls + +from modules.database import SessionVanityHr +from models.vanity_hr_models import HorarioEmpleadas, DataEmpleadas def _send_webhook(url: str, payload: dict): """Sends a POST request to a webhook.""" @@ -39,51 +39,88 @@ def _finalize_horario(telegram_id: int, data: dict): """Finalizes the 'horario' flow.""" logging.info(f"Finalizing 'horario' flow for telegram_id: {telegram_id}") - # 1. Prepare data + # 1. Prepare data for webhook and DB + day_pairs = [ + ("monday", "MONDAY_IN", "MONDAY_OUT"), + ("tuesday", "TUESDAY_IN", "TUESDAY_OUT"), + ("wednesday", "WEDNESDAY_IN", "WEDNESDAY_OUT"), + ("thursday", "THURSDAY_IN", "THURSDAY_OUT"), + ("friday", "FRIDAY_IN", "FRIDAY_OUT"), + ("saturday", "SATURDAY_IN", None), + ] + schedule_data = { "telegram_id": telegram_id, "short_name": data.get("SHORT_NAME"), - "monday_in": _convert_to_time(data.get("MONDAY_IN")), - "monday_out": _convert_to_time(data.get("MONDAY_OUT")), - "tuesday_in": _convert_to_time(data.get("TUESDAY_IN")), - "tuesday_out": _convert_to_time(data.get("TUESDAY_OUT")), - "wednesday_in": _convert_to_time(data.get("WEDNESDAY_IN")), - "wednesday_out": _convert_to_time(data.get("WEDNESDAY_OUT")), - "thursday_in": _convert_to_time(data.get("THURSDAY_IN")), - "thursday_out": _convert_to_time(data.get("THURSDAY_OUT")), - "friday_in": _convert_to_time(data.get("FRIDAY_IN")), - "friday_out": _convert_to_time(data.get("FRIDAY_OUT")), - "saturday_in": _convert_to_time(data.get("SATURDAY_IN")), - "saturday_out": _convert_to_time("6:00 PM"), # Hardcoded as per flow } + rows_for_db = [] + for day_key, in_key, out_key in day_pairs: + entrada = _convert_to_time(data.get(in_key)) + salida_raw = data.get(out_key) if out_key else "6:00 PM" + salida = _convert_to_time(salida_raw) + + schedule_data[f"{day_key}_in"] = entrada + schedule_data[f"{day_key}_out"] = salida + + if not entrada or not salida: + logging.warning(f"Missing schedule data for {day_key}. Entrada: {entrada}, Salida: {salida}") + continue + + rows_for_db.append( + { + "dia_semana": day_key, + "hora_entrada": entrada, + "hora_salida": salida, + } + ) + # 2. Send to webhook webhook_url = os.getenv("WEBHOOK_SCHEDULE") if webhook_url: - # Create a JSON-serializable payload - json_payload = {k: (v.isoformat() if isinstance(v, datetime.time) else v) for k, v in schedule_data.items()} + json_payload = { + k: (v.isoformat() if isinstance(v, time_cls) else v) for k, v in schedule_data.items() + } json_payload["timestamp"] = datetime.now().isoformat() _send_webhook(webhook_url, json_payload) - # 3. Save to database - engine = get_engine() - Session = sessionmaker(bind=engine) - session = Session() + # 3. Save to database (vanity_hr.horario_empleadas) + if not SessionVanityHr: + logging.error("SessionVanityHr is not initialized. Cannot persist horarios.") + return False + + session = SessionVanityHr() try: - # Upsert logic: Check if a record for this telegram_id already exists - existing_schedule = session.query(HorariosConfigurados).filter_by(telegram_id=telegram_id).first() - if existing_schedule: - # Update existing record - for key, value in schedule_data.items(): - setattr(existing_schedule, key, value) - existing_schedule.timestamp = datetime.now() - logging.info(f"Updating existing schedule for telegram_id: {telegram_id}") - else: - # Create new record - new_schedule = HorariosConfigurados(**schedule_data) - session.add(new_schedule) - logging.info(f"Creating new schedule for telegram_id: {telegram_id}") - + empleada = session.query(DataEmpleadas).filter(DataEmpleadas.telegram_chat_id == telegram_id).first() + numero_empleado = empleada.numero_empleado if empleada else None + if not numero_empleado: + logging.warning(f"No se encontró numero_empleado para telegram_id={telegram_id}. Se guardará NULL.") + + existing_rows = { + row.dia_semana: row + for row in session.query(HorarioEmpleadas).filter_by(telegram_id=telegram_id).all() + } + + for row in rows_for_db: + dia = row["dia_semana"] + entrada = row["hora_entrada"] + salida = row["hora_salida"] + existing = existing_rows.get(dia) + if existing: + existing.numero_empleado = numero_empleado or existing.numero_empleado + existing.hora_entrada_teorica = entrada + existing.hora_salida_teorica = salida + else: + session.add( + HorarioEmpleadas( + numero_empleado=numero_empleado, + telegram_id=telegram_id, + dia_semana=dia, + hora_entrada_teorica=entrada, + hora_salida_teorica=salida, + ) + ) + session.commit() return True except Exception as e: diff --git a/modules/flow_builder.py b/modules/flow_builder.py index 27f41da..dc48167 100644 --- a/modules/flow_builder.py +++ b/modules/flow_builder.py @@ -1,105 +1,153 @@ +import ast import json -import os import logging +import os from functools import partial -from telegram import Update, ReplyKeyboardRemove, ReplyKeyboardMarkup + +from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update from telegram.ext import ( + CommandHandler, ContextTypes, ConversationHandler, MessageHandler, - CommandHandler, filters, ) from .finalizer import finalize_flow -async def end_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - await update.message.reply_text("Flujo cancelado.") - return ConversationHandler.END -async def generic_callback(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict): - current_state_key = context.user_data.get("current_state", 0) - - current_step = next((step for step in flow["steps"] if step["state"] == current_state_key), None) - - if not current_step: - await update.message.reply_text("Hubo un error en el flujo. Por favor, inicia de nuevo.") - return ConversationHandler.END - - user_answer = update.message.text - variable_name = current_step.get("variable") - if variable_name: - context.user_data[variable_name] = user_answer - - next_state_key = None - if "next_steps" in current_step: - for condition in current_step["next_steps"]: - if condition.get("value") == user_answer: - next_state_key = condition["go_to"] - break - elif condition.get("value") == "default": - next_state_key = condition["go_to"] - elif "next_step" in current_step: - next_state_key = current_step["next_step"] - - if next_state_key is None: - return await end_cancel(update, context) - - if next_state_key == -1: - await finalize_flow(update, context) - return ConversationHandler.END - - next_step = next((step for step in flow["steps"] if step["state"] == next_state_key), None) - - if not next_step: - await update.message.reply_text("Error: No se encontró el siguiente paso del flujo.") - return ConversationHandler.END - - reply_markup = ReplyKeyboardRemove() - if next_step.get("type") == "keyboard" and "options" in next_step: - # Create a 2D array for the keyboard - options = next_step["options"] - keyboard = [options[i:i+2] for i in range(0, len(options), 2)] - reply_markup = ReplyKeyboardMarkup( - keyboard, - one_time_keyboard=True, resize_keyboard=True - ) - - await update.message.reply_text(next_step["question"], reply_markup=reply_markup) - - context.user_data["current_state"] = next_state_key - return next_state_key +def _build_keyboard(options): + keyboard = [options[i : i + 2] for i in range(0, len(options), 2)] + return ReplyKeyboardMarkup( + keyboard, + one_time_keyboard=True, + resize_keyboard=True, + ) -async def start_flow(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict): - context.user_data.clear() - context.user_data["flow_name"] = flow["flow_name"] - - first_step = flow["steps"][0] - context.user_data["current_state"] = first_step["state"] +def _preprocess_flow(flow: dict): + """Populate missing next_step values assuming a linear order.""" + steps = flow.get("steps", []) + for idx, step in enumerate(steps): + if "next_step" in step or "next_steps" in step: + continue + if idx + 1 < len(steps): + step["next_step"] = steps[idx + 1]["state"] + else: + step["next_step"] = -1 - reply_markup = ReplyKeyboardRemove() - if first_step.get("type") == "keyboard" and "options" in first_step: - options = first_step["options"] - keyboard = [options[i:i+2] for i in range(0, len(options), 2)] - reply_markup = ReplyKeyboardMarkup( - keyboard, - one_time_keyboard=True, resize_keyboard=True - ) - - await update.message.reply_text(first_step["question"], reply_markup=reply_markup) - return first_step["state"] + +def _find_step(flow: dict, state_key): + return next((step for step in flow["steps"] if step["state"] == state_key), None) + + +ALLOWED_AST_NODES = ( + ast.Expression, + ast.BoolOp, + ast.Compare, + ast.Name, + ast.Load, + ast.Constant, + ast.List, + ast.Tuple, + ast.And, + ast.Or, + ast.Eq, + ast.NotEq, + ast.In, + ast.NotIn, +) + + +def _evaluate_condition(condition: str, response: str) -> bool: + """Safely evaluate expressions like `response in ['Hoy', 'Mañana']`.""" + if not condition: + return False + try: + tree = ast.parse(condition, mode="eval") + for node in ast.walk(tree): + if not isinstance(node, ALLOWED_AST_NODES): + raise ValueError(f"Unsupported expression: {condition}") + compiled = compile(tree, "", "eval") + return bool(eval(compiled, {"__builtins__": {}}, {"response": response})) + except Exception as exc: + logging.warning("Failed to evaluate condition '%s': %s", condition, exc) + return False + + +def _determine_next_state(step: dict, user_answer: str): + """Resolve the next state declared in the JSON step.""" + if "next_steps" in step: + default_target = None + for option in step["next_steps"]: + value = option.get("value") + if value == "default": + default_target = option.get("go_to") + elif user_answer == value: + return option.get("go_to") + return default_target + + next_step = step.get("next_step") + + if isinstance(next_step, list): + default_target = None + for option in next_step: + condition = option.get("condition") + target = option.get("state") + if condition: + if _evaluate_condition(condition, user_answer): + return target + elif option.get("value") and user_answer == option["value"]: + return target + elif option.get("default"): + default_target = target + return default_target + + return next_step + + +async def _go_to_state(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict, state_key): + """Send the question for the requested state, skipping info-only steps.""" + safety_counter = 0 + while True: + safety_counter += 1 + if safety_counter > len(flow["steps"]) + 2: + logging.error("Detected potential loop while traversing flow '%s'", flow.get("flow_name")) + await update.message.reply_text("Ocurrió un error al continuar con el flujo. Intenta iniciar de nuevo.") + return ConversationHandler.END + + if state_key == -1: + await finalize_flow(update, context) + return ConversationHandler.END + + next_step = _find_step(flow, state_key) + if not next_step: + await update.message.reply_text("Error: No se encontró el siguiente paso del flujo.") + return ConversationHandler.END + + reply_markup = ReplyKeyboardRemove() + if next_step.get("type") == "keyboard" and "options" in next_step: + reply_markup = _build_keyboard(next_step["options"]) + + await update.message.reply_text(next_step["question"], reply_markup=reply_markup) + context.user_data["current_state"] = state_key + + if next_step.get("type") == "info": + state_key = _determine_next_state(next_step, None) + if state_key is None: + await update.message.reply_text("No se pudo continuar con el flujo actual. Intenta iniciar de nuevo.") + return ConversationHandler.END + continue + + return state_key def create_handler(flow: dict): states = {} all_states = sorted(list(set([step["state"] for step in flow["steps"]]))) - for state_key in all_states: - # Skip the end state if state_key == -1: continue - callback = partial(generic_callback, flow=flow) states[state_key] = [MessageHandler(filters.TEXT & ~filters.COMMAND, callback)] @@ -112,19 +160,54 @@ def create_handler(flow: dict): allow_reentry=True, ) + +async def end_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + await update.message.reply_text("Flujo cancelado.") + return ConversationHandler.END + + +async def generic_callback(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict): + current_state_key = context.user_data.get("current_state", 0) + current_step = _find_step(flow, current_state_key) + + if not current_step: + await update.message.reply_text("Hubo un error en el flujo. Por favor, inicia de nuevo.") + return ConversationHandler.END + + user_answer = update.message.text + variable_name = current_step.get("variable") + if variable_name: + context.user_data[variable_name] = user_answer + + next_state_key = _determine_next_state(current_step, user_answer) + if next_state_key is None: + return await end_cancel(update, context) + + return await _go_to_state(update, context, flow, next_state_key) + + +async def start_flow(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict): + context.user_data.clear() + context.user_data["flow_name"] = flow["flow_name"] + + first_state = flow["steps"][0]["state"] + return await _go_to_state(update, context, flow, first_state) + + def load_flows(): flow_handlers = [] flow_dir = "conv-flows" if not os.path.isdir(flow_dir): logging.warning(f"Directory not found: {flow_dir}") return flow_handlers - + for filename in os.listdir(flow_dir): if filename.endswith(".json"): filepath = os.path.join(flow_dir, filename) with open(filepath, "r", encoding="utf-8") as f: try: flow_definition = json.load(f) + _preprocess_flow(flow_definition) handler = create_handler(flow_definition) flow_handlers.append(handler) logging.info(f"Flow '{flow_definition['flow_name']}' loaded successfully.") diff --git a/modules/onboarding.py b/modules/onboarding.py index 8753af3..7dbb667 100644 --- a/modules/onboarding.py +++ b/modules/onboarding.py @@ -434,7 +434,7 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: await update.message.reply_text( - "Proceso cancelado. ⏸️\nPuedes retomarlo con /welcome o ir al menú con /start.", + "Proceso cancelado. ⏸️\nPuedes retomarlo con /registro (alias /welcome) o ir al menú con /start.", reply_markup=main_actions_keyboard() ) context.user_data.clear() @@ -450,8 +450,11 @@ states[34] = [MessageHandler(filters.TEXT & ~filters.COMMAND, finalizar)] # Handler listo para importar en main.py onboarding_handler = ConversationHandler( - entry_points=[CommandHandler("welcome", start)], # Cambiado a /welcome - states=states, # Tu diccionario de estados + entry_points=[ + CommandHandler("welcome", start), + CommandHandler("registro", start), + ], + states=states, fallbacks=[CommandHandler("cancelar", cancelar)], allow_reentry=True ) diff --git a/modules/ui.py b/modules/ui.py index d21e735..2b9b688 100644 --- a/modules/ui.py +++ b/modules/ui.py @@ -5,7 +5,7 @@ def main_actions_keyboard(is_registered: bool = False) -> ReplyKeyboardMarkup: """Teclado inferior con comandos directos (un toque lanza el flujo).""" keyboard = [] if not is_registered: - keyboard.append(["/welcome"]) + keyboard.append(["/registro"]) keyboard.extend([ ["/vacaciones", "/permiso"],