commit 36b7154c6e3046b78f162aa40a43d27545ceda3d Author: Marco Gallegos Date: Mon Dec 22 22:47:33 2025 -0600 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0a73b17 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.env +.env.bak +__pycache__/ +.git/ +.gitignore +.venv/ +venv/ +*.pyc +*.pyo +*.pyd +*.log +dist/ +build/ +node_modules/ +README.md +Vanessa.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..154fe6d --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# Configuración de Telegram +TELEGRAM_TOKEN=TU_TOKEN_NUEVO_AQUI +TELEGRAM_ADMIN_CHAT_ID=TELEGRAM_ADMIN_CHAT_ID +OPENAI_API_KEY=sk-proj-xxxx +GOOGLE_API_KEY=AIzaSyBqH5... # Usado para Gemini AI en modules/ai.py + +# =============================== +# WEBHOOKS +# =============================== +WEBHOOK_ONBOARDING=url +WEBHOOK_VACACIONES=url +WEBHOOK_PERMISOS=url +WEBHOOK_PRINTS=url +WEBHOOK_SCHEDULE=url + +# =============================== +# LINKS +# =============================== +LINK_CURSOS=https://cursos.vanityexperience.mx/dashboard-2/ +LINK_SITIO=https://vanityexperience.mx/ +LINK_AGENDA_IOS=https://apps.apple.com/us/app/fresha-for-business/id1455346253 +LINK_AGENDA_ANDROID=https://play.google.com/store/apps/details?id=com.fresha.Business + + +# =============================== +# DATABASE SETUP +# =============================== +MYSQL_HOST=db +MYSQL_USER=user +MYSQL_PASSWORD=password +MYSQL_ROOT_PASSWORD=rootpassword + +# Database Names +MYSQL_DATABASE_USERS_ALMA=USERS_ALMA +MYSQL_DATABASE_VANITY_HR=vanity_hr +MYSQL_DATABASE_VANITY_ATTENDANCE=vanity_attendance + +# =============================== +# EMAIL SETUP +# =============================== +SMTP_SERVER=smtp.hostinger.com +SMTP_PORT=587 +SMTP_USER=your_email@example.com +SMTP_PASSWORD=your_password +IMAP_SERVER=imap.hostinger.com +IMAP_PORT=993 +IMAP_USER=your_email@example.com +IMAP_PASSWORD=your_password +PRINTER_EMAIL=your_printer_email@example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ff3fe2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Environments +.env +.env.bak +.venv/ +venv/ +.idea/ + +# Byte-compiled / optimized / C extensions +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +*.log +.pytest_cache/ +.hypothesis/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d1462a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# Usar una imagen base de Python actualizada +FROM python:3.12-slim + +# Establecer el directorio de trabajo +WORKDIR /app + +# Copiar los archivos de requisitos e instalar dependencias +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copiar el resto del código de la aplicación +COPY . . + +# Cambiar al directorio donde está el código fuente +WORKDIR /app/app + +# Comando para ejecutar la aplicación +CMD ["python", "main.py"] diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..78b9788 --- /dev/null +++ b/Readme.md @@ -0,0 +1,95 @@ +# Vanessa Bot - README Final + +## Resumen del Proyecto +Vanessa Bot es un asistente virtual modular para Recursos Humanos en Vanity/Soul, construido con una arquitectura desacoplada. Temporalmente, el bot solo envía solicitudes vía webhooks a n8n sin esperar respuesta inmediata, enfocándonos en flujos conversacionales dinámicos. La DB está removida inicialmente, con roadmap para implementar persistencia externa o nativa. + +## Nueva Estructura del Proyecto +- **main.py**: Orquestador puro que registra handlers y coordina módulos. No contiene lógica de negocio. +- **/modules/**: Módulos especializados (rh_requests.py, flow_builder.py, ui.py, logger.py, ai.py, finalizer.py). +- **/conv-flows/**: Archivos JSON que definen flujos conversacionales (e.g., horario.json). +- **.env.example**: Variables de entorno (Telegram token, webhooks; sin DB). +- **requirements.txt**: Dependencias Python. +- **Docker**: Para despliegue (Dockerfile, docker-compose.yml). +- **Archivos eliminados**: db_logic.md, bot_brain.md, Vanessa.md, Readme.md (original), models/, init/ (temporalmente, ya que DB está fuera). + +## Arquitectura y Funcionamiento +### Flujo Actual (Temporal: Solo Envío de Webhooks) +1. Usuario inicia comando (e.g., /vacaciones). +2. main.py enruta a handler en módulo. +3. Módulo procesa conversación, valida, envía webhook a n8n. +4. n8n procesa y guarda en DB remota. +5. Bot responde inmediatamente al usuario (sin esperar confirmación). + +### Nuevo Enfoque Propuesto (Bidireccional) +Cambiar a webhooks bidireccionales donde el bot espera respuesta de n8n antes de notificar al usuario, garantizando persistencia. + +## Componentes Clave +### 1. main.py +- Registra comandos y handlers desde módulos. +- Setup de Telegram bot. + +### 2. /modules +- **rh_requests.py**: Solicitudes de vacaciones/permisos; envía webhooks. +- **flow_builder.py**: Carga y ejecuta flujos desde JSON en /conv-flows. +- **ui.py**: Teclados y menús. +- **logger.py**: Logging. +- **ai.py**: Clasificación IA. +- **finalizer.py**: Finaliza flujos. + +### 3. /conv-flows +- JSON con steps: state, type (keyboard/text/info), question, options, next_step. +- Ejemplo: horario.json para definir horarios. + +## Roadmap +### Fase 1 (Actual): Sin DB, Webhooks Unidireccionales +- Flujos conversacionales funcionales. +- Envío de payloads a n8n. + +### Fase 2: Webhooks Bidireccionales +- Agregar receptor de webhooks (Flask en main.py). +- Esperar respuesta de n8n para confirmar usuario. +- Storage temporal para solicitudes pendientes. + +### Fase 3: Implementar DB +- DB nativa (SQLite/PostgreSQL) o acceso remoto en VPS. +- Persistencia local para reporting. + +### Fase 4: Escalabilidad y Mejoras +- Manejo concurrente, logging avanzado, integraciones. + +## Instalación y Ejecución +1. Clonar repo. +2. `cp .env.example .env` y configurar (TOKEN, WEBHOOK_*). +3. `pip install -r requirements.txt` +4. `python main.py` o `docker-compose up` + +## Problema Actual y Solución +- **Problema**: Pérdida de datos en DB al mover desde n8n. +- **Temporal**: Bot responde inmediatamente. +- **Futuro**: Esperar confirmación para asegurar persistencia. + +## Beneficios del Approach +- Modularidad: Fácil extender. +- Desacoplamiento: Sin DB local inicial. +- Escalabilidad: Webhooks para cualquier backend. +- Mantenibilidad: Código limpio. + +## Pasos de Implementación (Detalle en new_plan.md) +- Análisis de flujo actual. +- Diseño bidireccional. +- Agregar receptor webhooks. +- Modificar módulos. +- Storage pendiente. +- Actualizar n8n. +- Pruebas. + +## Timeline +- Análisis: 1-2 días +- Diseño: 1 día +- Implementación: 3-5 días +- Pruebas: 2-3 días + +## Consideraciones Técnicas +- Timeout para callbacks. +- Seguridad en webhooks. +- Escalabilidad con Redis si necesario. \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/conv-flows/horario.json b/app/conv-flows/horario.json new file mode 100644 index 0000000..d797e5a --- /dev/null +++ b/app/conv-flows/horario.json @@ -0,0 +1,107 @@ +{ + "flow_name": "horario", + "steps": [ + { + "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\nCuando estés lista, confirma para continuar.", + "options": ["Continuar"] + }, + { + "state": -2, + "variable": "SHORT_NAME", + "type": "text", + "question": "NOMBRE CORTO\n\nEste nombre se usará para mensajes internos, reportes y notificaciones." + }, + { + "state": 1, + "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"] + }, + { + "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"] + }, + { + "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"] + }, + { + "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"] + }, + { + "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"] + }, + { + "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"] + }, + { + "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"] + }, + { + "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"] + }, + { + "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"] + }, + { + "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"] + }, + { + "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"] + }, + { + "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." + }, + { + "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." + } + ] +} diff --git a/app/conv-flows/leave_request.json b/app/conv-flows/leave_request.json new file mode 100644 index 0000000..04cc559 --- /dev/null +++ b/app/conv-flows/leave_request.json @@ -0,0 +1,95 @@ +{ + "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": "¿Para cuándo lo necesitas?", + "type": "keyboard", + "options": ["Hoy", "Mañana", "Pasado mañana", "Fecha específica"], + "next_step": [ + { + "condition": "response in ['Hoy', 'Mañana', 'Pasado mañana']", + "state": "HORARIO" + }, + { + "condition": "response == 'Fecha específica'", + "state": "PERMISO_ANIO" + } + ] + }, + { + "state": "PERMISO_ANIO", + "question": "¿Para qué año es el permiso? (elige el actual o el siguiente)", + "type": "keyboard", + "options": ["current_year", "next_year"], + "next_step": "INICIO_DIA" + }, + { + "state": "INICIO_DIA", + "question": "¿En qué *día* inicia el permiso? (número, ej: 12)", + "type": "text", + "next_step": "INICIO_MES" + }, + { + "state": "INICIO_MES", + "question": "¿De qué *mes* inicia?", + "type": "keyboard", + "options": [ + "Enero", "Febrero", "Marzo", + "Abril", "Mayo", "Junio", + "Julio", "Agosto", "Septiembre", + "Octubre", "Noviembre", "Diciembre" + ], + "next_step": "FIN_DIA" + }, + { + "state": "FIN_DIA", + "question": "¿Qué *día* termina?", + "type": "text", + "next_step": "FIN_MES" + }, + { + "state": "FIN_MES", + "question": "¿De qué *mes* termina?", + "type": "keyboard", + "options": [ + "Enero", "Febrero", "Marzo", + "Abril", "Mayo", "Junio", + "Julio", "Agosto", "Septiembre", + "Octubre", "Noviembre", "Diciembre" + ], + "next_step": "HORARIO" + }, + { + "state": "HORARIO", + "question": "¿Cuál es el horario? Ej: `09:00-11:00` o `Todo el día`.", + "type": "text", + "next_step": "MOTIVO" + }, + { + "state": "MOTIVO", + "question": "Entendido. ¿Cuál es el motivo o comentario adicional?", + "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" + } + ] +} diff --git a/app/conv-flows/onboarding.json b/app/conv-flows/onboarding.json new file mode 100644 index 0000000..5cc8b0d --- /dev/null +++ b/app/conv-flows/onboarding.json @@ -0,0 +1,458 @@ +{ + "flow_name": "onboarding", + "commands": [ + "onboarding", + "registro", + "welcome" + ], + "guards": [ + "require_new_user" + ], + "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" + }, + { + "state": 1, + "variable": "NOMBRE_COMPLETO", + "question": "Escribe tus nombres (SIN apellidos), exactamente como aparecen en tu INE.", + "type": "text" + }, + { + "state": 2, + "variable": "APELLIDO_PATERNO", + "question": "Apellido paterno:", + "type": "text" + }, + { + "state": 3, + "variable": "APELLIDO_MATERNO", + "question": "Apellido materno:", + "type": "text" + }, + { + "state": 4, + "variable": "CUMPLE_DIA", + "question": "Fecha de nacimiento · Día (solo número, ej. 13)", + "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" + ] + }, + { + "state": 6, + "variable": "CUMPLE_ANIO", + "question": "Fecha de nacimiento · Año (4 dígitos)", + "type": "text" + }, + { + "state": 7, + "variable": "ESTADO_NACIMIENTO", + "question": "Estado de nacimiento\n\nSelecciona el estado donde naciste.\nSi no aparece, elige *Otro*.", + "type": "keyboard", + "options": [ + "Coahuila", + "Nuevo León", + "Otro" + ], + "next_steps": [ + { + "value": "Otro", + "go_to": 7.1 + }, + { + "value": "default", + "go_to": 7.5 + } + ] + }, + { + "state": 7.1, + "variable": "ESTADO_NACIMIENTO_OTRO", + "question": "Escribe el nombre del estado donde naciste.", + "type": "text", + "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" + }, + { + "state": 9, + "variable": "CURP", + "question": "CURP completo (18 caracteres):", + "type": "text" + }, + { + "state": 10, + "variable": "CORREO", + "question": "Correo electrónico personal:", + "type": "text" + }, + { + "state": 11, + "variable": "CELULAR", + "question": "Número de celular (10 dígitos):", + "type": "text" + }, + { + "state": 12, + "variable": "CALLE", + "question": "Domicilio · Calle:", + "type": "text" + }, + { + "state": 13, + "variable": "NUM_EXTERIOR", + "question": "Domicilio · Número exterior:", + "type": "text" + }, + { + "state": 14, + "variable": "NUM_INTERIOR", + "question": "Domicilio · Número interior (0 si no aplica):", + "type": "text" + }, + { + "state": 15, + "variable": "COLONIA", + "question": "Domicilio · Colonia:", + "type": "text" + }, + { + "state": 16, + "variable": "CODIGO_POSTAL", + "question": "Código Postal (5 dígitos):", + "type": "text" + }, + { + "state": 17, + "variable": "CIUDAD_RESIDENCIA", + "question": "Ciudad de residencia:", + "type": "keyboard", + "options": [ + "Saltillo", + "Ramos Arizpe", + "Arteaga", + "Otro" + ], + "next_steps": [ + { + "value": "Otro", + "go_to": 17.1 + }, + { + "value": "default", + "go_to": 18 + } + ] + }, + { + "state": 17.1, + "variable": "CIUDAD_RESIDENCIA_OTRO", + "question": "Escribe tu ciudad de residencia:", + "type": "text", + "next_step": 18 + }, + { + "state": 18, + "variable": "ROL", + "question": "Rol dentro del equipo:", + "type": "keyboard", + "options": [ + "Belleza", + "Staff (Recepción)", + "Marketing" + ] + }, + { + "state": 19, + "variable": "SUCURSAL", + "question": "Sucursal principal:", + "type": "keyboard", + "options": [ + "Plaza Cima (Sur)", + "Plaza O (Carranza)" + ] + }, + { + "state": 20, + "variable": "INICIO_DIA", + "question": "Fecha de ingreso · Día:", + "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" + ] + }, + { + "state": 22, + "variable": "INICIO_ANIO", + "question": "Fecha de ingreso · Año:", + "type": "keyboard", + "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": "EMERGENCIA_NOMBRE", + "question": "Contacto de emergencia · Nombre completo:", + "type": "text" + }, + { + "state": 24, + "variable": "EMERGENCIA_TEL", + "question": "Contacto de emergencia · Teléfono:", + "type": "text" + }, + { + "state": 25, + "variable": "EMERGENCIA_RELACION", + "question": "Relación con el contacto de emergencia:", + "type": "keyboard", + "options": [ + "Padre/Madre", + "Pareja", + "Hermano/a", + "Hijo/a", + "Amigo/a", + "Otro" + ], + "next_steps": [ + { + "value": "Otro", + "go_to": 25.1 + }, + { + "value": "default", + "go_to": 26 + } + ] + }, + { + "state": 25.1, + "variable": "EMERGENCIA_RELACION_OTRA", + "question": "Describe la relación con tu contacto de emergencia:", + "type": "text", + "next_step": 26 + }, + { + "state": 26, + "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": "REF1_NOMBRE", + "question": "Referencia 1 · Nombre completo:", + "type": "text" + }, + { + "state": 28, + "variable": "REF1_TELEFONO", + "question": "Referencia 1 · Teléfono:", + "type": "text" + }, + { + "state": 29, + "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": "REF2_NOMBRE", + "question": "Referencia 2 · Nombre completo:", + "type": "text" + }, + { + "state": 31, + "variable": "REF2_TELEFONO", + "question": "Referencia 2 · Teléfono:", + "type": "text" + }, + { + "state": 32, + "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": "REF3_NOMBRE", + "question": "Referencia 3 · Nombre completo:", + "type": "text" + }, + { + "state": 34, + "variable": "REF3_TELEFONO", + "question": "Referencia 3 · Teléfono:", + "type": "text" + }, + { + "state": 35, + "variable": "REF3_TIPO", + "question": "Referencia 3 · Relación:", + "type": "keyboard", + "options": [ + "Familiar", + "Amistad", + "Trabajo", + "Académica", + "Otra" + ], + "next_steps": [ + { + "value": "Otra", + "go_to": 35.1 + }, + { + "value": "default", + "go_to": 99 + } + ] + }, + { + "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" + } + ] +} diff --git a/app/conv-flows/vacations.json b/app/conv-flows/vacations.json new file mode 100644 index 0000000..4c5d914 --- /dev/null +++ b/app/conv-flows/vacations.json @@ -0,0 +1,80 @@ +{ + "flow_name": "vacations", + "steps": [ + { + "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": 1, + "variable": "INICIO_MES", + "question": "¿De qué *mes* inicia?", + "type": "keyboard", + "options": [ + "Enero", "Febrero", "Marzo", + "Abril", "Mayo", "Junio", + "Julio", "Agosto", "Septiembre", + "Octubre", "Noviembre", "Diciembre" + ] + }, + { + "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": 3, + "variable": "FIN_DIA", + "question": "¿Qué *día* termina tu descanso?", + "type": "text" + }, + { + "state": 4, + "variable": "FIN_MES", + "question": "¿De qué *mes* termina?", + "type": "keyboard", + "options": [ + "Enero", "Febrero", "Marzo", + "Abril", "Mayo", "Junio", + "Julio", "Agosto", "Septiembre", + "Octubre", "Noviembre", "Diciembre" + ] + }, + { + "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": 6, + "variable": "MOTIVO", + "question": "Entendido. ¿Cuál es el motivo o comentario adicional?", + "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" + } + ] +} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a3ebd19 --- /dev/null +++ b/app/main.py @@ -0,0 +1,161 @@ +import os +import logging +from dotenv import load_dotenv +from typing import Optional + +# Cargar variables de entorno antes de importar módulos que las usan +load_dotenv() + +from telegram import ( + Update, + InlineKeyboardButton, + InlineKeyboardMarkup, + BotCommand, +) +from telegram.constants import ParseMode +from telegram.ext import Application, Defaults, CommandHandler, ContextTypes + +# --- IMPORTAR HABILIDADES --- +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.rh_requests import vacaciones_handler, permiso_handler +# from modules.finder import finder_handler (Si lo creas después) + +# Cargar links desde variables de entorno +LINK_CURSOS = os.getenv( + "LINK_CURSOS", "https://cursos.vanityexperience.mx/dashboard-2/" +) +LINK_SITIO = os.getenv("LINK_SITIO", "https://vanityexperience.mx/") +LINK_AGENDA_IOS = os.getenv( + "LINK_AGENDA_IOS", "https://apps.apple.com/us/app/fresha-for-business/id1455346253" +) +LINK_AGENDA_ANDROID = os.getenv( + "LINK_AGENDA_ANDROID", + "https://play.google.com/store/apps/details?id=com.fresha.Business", +) + + +TOKEN = os.getenv("TELEGRAM_TOKEN") + +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO +) + + +def _guess_platform(update: Update) -> Optional[str]: + """ + Telegram no expone el OS del usuario en mensajes regulares. + Devolvemos None para mostrar ambos links; si en el futuro llegan datos, se pueden mapear aquí. + """ + try: + _ = update.to_dict() # placeholder por si queremos inspeccionar el payload + except Exception: + pass + return None + + +async def links_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Muestra accesos rápidos a cursos, sitio y descargas.""" + user = update.effective_user + log_request(user.id, user.username, "links", update.message.text) + + plataforma = _guess_platform(update) + descarga_buttons = [] + if plataforma == "ios": + descarga_buttons.append( + InlineKeyboardButton("Agenda | iOS", url=LINK_AGENDA_IOS) + ) + elif plataforma == "android": + descarga_buttons.append( + InlineKeyboardButton("Agenda | Android", url=LINK_AGENDA_ANDROID) + ) + else: + descarga_buttons = [ + InlineKeyboardButton("Agenda | iOS", url=LINK_AGENDA_IOS), + InlineKeyboardButton("Agenda | Android", url=LINK_AGENDA_ANDROID), + ] + + texto = ( + "🌐 Links útiles\n" + "Claro, aquí tienes enlaces que puedes necesitar durante tu estancia con nosotros:\n" + "Toca el que te aplique." + ) + botones = [ + [InlineKeyboardButton("Cursos Vanity", url=LINK_CURSOS)], + [InlineKeyboardButton("Sitio Vanity", url=LINK_SITIO)], + descarga_buttons, + ] + await update.message.reply_text(texto, reply_markup=InlineKeyboardMarkup(botones)) + + +async def menu_principal(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Muestra el menú de opciones de Vanessa""" + user = update.effective_user + log_request(user.id, user.username, "start", update.message.text) + texto = ( + "👩‍💼 **Hola, soy Vanessa. ¿En qué puedo ayudarte hoy?**\n\n" + "Toca un botón para continuar 👇" + ) + is_registered = chat_id_exists(user.id) + await update.message.reply_text( + texto, reply_markup=main_actions_keyboard(is_registered=is_registered) + ) + + +async def post_init(application: Application): + # Mantén los comandos rápidos disponibles en el menú de Telegram + await application.bot.set_my_commands( + [ + BotCommand("start", "Mostrar menú principal"), + # BotCommand("welcome", "Registro de nuevas empleadas"), # Se maneja dinámicamente + BotCommand("horario", "Definir horario de trabajo"), + BotCommand("vacaciones", "Solicitar vacaciones"), + BotCommand("permiso", "Solicitar permiso por horas"), + BotCommand("links", "Links útiles"), + BotCommand("cancelar", "Cancelar operación actual"), + ] + ) + + # Cargar flujos dinámicos + handlers = load_flows() + for handler in handlers: + application.add_handler(handler) + + logging.info("🤖 Vanessa Bot iniciado y listo.") + + +def main(): + """Punto de entrada""" + if not TOKEN: + logging.error("❌ No se encontró TELEGRAM_TOKEN en variables de entorno.") + return + + defaults = Defaults(parse_mode=ParseMode.MARKDOWN) + app = ( + Application.builder() + .token(TOKEN) + .defaults(defaults) + .post_init(post_init) + .build() + ) + + # Handlers globales + app.add_handler(CommandHandler("start", menu_principal)) + app.add_handler(CommandHandler("help", menu_principal)) + app.add_handler(CommandHandler("links", links_menu)) + + # Handlers de módulos + app.add_handler(vacaciones_handler) + app.add_handler(permiso_handler) + + # El flow_builder carga el resto desde conv-flows/ + + logging.info("Iniciando polling...") + app.run_polling() + + +if __name__ == "__main__": + main() diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/ai.py b/app/modules/ai.py new file mode 100644 index 0000000..a68718f --- /dev/null +++ b/app/modules/ai.py @@ -0,0 +1,55 @@ +import os +import importlib +import importlib.metadata as importlib_metadata + +# Compatibilidad para entornos donde packages_distributions no existe (p.ej. Python 3.9 con importlib recortado). +if not hasattr(importlib_metadata, "packages_distributions"): + try: + import importlib_metadata as backport_metadata # type: ignore + if hasattr(backport_metadata, "packages_distributions"): + importlib_metadata.packages_distributions = backport_metadata.packages_distributions # type: ignore[attr-defined] + else: + importlib_metadata.packages_distributions = lambda: {} # type: ignore[assignment] + except Exception: + importlib_metadata.packages_distributions = lambda: {} # type: ignore[assignment] + +import google.generativeai as genai + +def classify_reason(text: str) -> str: + """ + Clasifica el motivo de un permiso utilizando la API de Gemini. + + Args: + text: El motivo del permiso proporcionado por el usuario. + + Returns: + La categoría clasificada (EMERGENCIA, MÉDICO, TRÁMITE, PERSONAL) o "OTRO" si no se puede clasificar. + """ + try: + genai.configure(api_key=os.getenv("GOOGLE_API_KEY")) + + model = genai.GenerativeModel('gemini-pro') + + prompt = f""" + Clasifica el siguiente motivo de solicitud de permiso en una de estas cuatro categorías: EMERGENCIA, MÉDICO, TRÁMITE, PERSONAL. + Responde únicamente con la palabra de la categoría en mayúsculas. + + Motivo: "{text}" + Categoría: + """ + + response = model.generate_content(prompt) + + # Limpiar la respuesta para obtener solo la categoría + category = response.text.strip().upper() + + # Validar que la categoría sea una de las esperadas + valid_categories = ["EMERGENCIA", "MÉDICO", "TRÁMITE", "PERSONAL"] + if category in valid_categories: + return category + else: + return "PERSONAL" # Si la IA devuelve algo inesperado, se asigna a PERSONAL + + except Exception as e: + print(f"Error al clasificar con IA: {e}") + return "PERSONAL" # En caso de error, se asigna a PERSONAL por defecto diff --git a/app/modules/database.py b/app/modules/database.py new file mode 100644 index 0000000..3330209 --- /dev/null +++ b/app/modules/database.py @@ -0,0 +1,31 @@ +import logging + +# MOCK DATABASE MODULE +# Since the actual DB is removed temporarily, this module provides mock implementations. + +SessionUsersAlma = None +SessionVanityHr = None +SessionVanityAttendance = None + +_REGISTERED_USERS = set() + +def chat_id_exists(chat_id: int) -> bool: + """Mock check: returns True if user is in in-memory set.""" + return int(chat_id) in _REGISTERED_USERS + +def register_user(user_data: dict) -> bool: + """Mock register: adds user to in-memory set.""" + try: + meta = user_data.get("meta", {}) + metadata = user_data.get("metadata", {}) + tid = meta.get("telegram_id") or metadata.get("telegram_id") or metadata.get("chat_id") + + if tid: + _REGISTERED_USERS.add(int(tid)) + logging.info(f"[MockDB] User {tid} registered in memory.") + return True + logging.warning("[MockDB] Could not find telegram_id in user_data.") + return False + except Exception as e: + logging.error(f"[MockDB] Register error: {e}") + return False diff --git a/app/modules/finalizer.py b/app/modules/finalizer.py new file mode 100644 index 0000000..d8cb0c3 --- /dev/null +++ b/app/modules/finalizer.py @@ -0,0 +1,499 @@ +import os +import json +import logging +import requests +from datetime import datetime, time as time_cls + +from modules.database import SessionVanityHr, register_user +from modules.ui import main_actions_keyboard +# from models.vanity_hr_models import HorarioEmpleadas, DataEmpleadas (Removed) + +WEBHOOK_ONBOARDING_URLS = [ + w.strip() + for w in ( + os.getenv("WEBHOOK_ONBOARDING") or os.getenv("WEBHOOK_CONTRATO") or "" + ).split(",") + if w and w.strip() +] + +BOT_VERSION = os.getenv("ONBOARDING_BOT_VERSION", "welcome2soul_v2") + +MAPA_MESES = { + "Enero": "01", + "Febrero": "02", + "Marzo": "03", + "Abril": "04", + "Mayo": "05", + "Junio": "06", + "Julio": "07", + "Agosto": "08", + "Septiembre": "09", + "Octubre": "10", + "Noviembre": "11", + "Diciembre": "12", +} + +MAPA_SUCURSALES = { + "Plaza Cima (Sur)": "plaza_cima", + "Plaza O (Carranza)": "plaza_o", +} + +DEFAULT_MESSAGES = { + "horario": { + "success": "¡Horario guardado con éxito! 👍", + "error": "Ocurrió un error al guardar tu horario. Por favor, contacta a un administrador.", + }, + "default": { + "success": "Flujo completado correctamente.", + "error": "Ocurrió un error al completar el flujo. Intenta nuevamente o contacta a un administrador.", + }, +} + + +def _clean_text(text: str) -> str: + if text is None: + return "" + return " ".join(str(text).split()) + + +def _normalize_id(value: str) -> str: + if not value: + return "N/A" + cleaned = "".join(str(value).split()).upper() + return "N/A" if cleaned in {"", "0"} else cleaned + + +def _num_to_words_es_hasta_999(n: int) -> str: + if n < 0 or n > 999: + return str(n) + unidades = [ + "cero", + "uno", + "dos", + "tres", + "cuatro", + "cinco", + "seis", + "siete", + "ocho", + "nueve", + ] + especiales = { + 10: "diez", + 11: "once", + 12: "doce", + 13: "trece", + 14: "catorce", + 15: "quince", + 20: "veinte", + 30: "treinta", + 40: "cuarenta", + 50: "cincuenta", + 60: "sesenta", + 70: "setenta", + 80: "ochenta", + 90: "noventa", + 100: "cien", + 200: "doscientos", + 300: "trescientos", + 400: "cuatrocientos", + 500: "quinientos", + 600: "seiscientos", + 700: "setecientos", + 800: "ochocientos", + 900: "novecientos", + } + if n < 10: + return unidades[n] + if n in especiales: + return especiales[n] + if n < 20: + return "dieci" + unidades[n - 10] + if n < 30: + return "veinti" + unidades[n - 20] + if n < 100: + decenas = (n // 10) * 10 + resto = n % 10 + return f"{especiales[decenas]} y {unidades[resto]}" + centenas = (n // 100) * 100 + resto = n % 100 + prefijo = ( + "ciento" + if centenas == 100 and resto > 0 + else especiales.get(centenas, str(centenas)) + ) + if resto == 0: + return prefijo + return f"{prefijo} {_num_to_words_es_hasta_999(resto)}" + + +def numero_a_texto(num_ext: str, num_int: str) -> str: + import re + + texto_base = _clean_text(num_ext) + interior = _clean_text(num_int) + match = re.match(r"(\d+)", texto_base) + if not match: + return texto_base + numero = int(match.group(1)) + en_letras = _num_to_words_es_hasta_999(numero) + if interior and interior.lower() not in {"", "n/a"}: + return f"{en_letras}, interior {interior}".strip() + return en_letras + + +def _extract_responses(data: dict) -> dict: + reserved = {"flow_name", "current_state"} + return { + key: value + for key, value in data.items() + if key not in reserved and not str(key).startswith("_") + } + + +def _month_to_number(value: str) -> str: + return MAPA_MESES.get(value, value or "01") + + +def _resolve_other(responses: dict, key: str, other_key: str) -> str: + value = responses.get(key, "N/A") + if value in {"Otro", "Otra"}: + other = _clean_text(responses.get(other_key, value)) + return other or value + return value + + +def _build_date(year: str, month: str, day: str) -> str: + try: + day_num = str(day).zfill(2) + month_num = _month_to_number(month) + return f"{year}-{month_num}-{day_num}" + except Exception: + return "ERROR_FECHA" + + +def _prepare_onboarding_payload(responses: dict, meta: dict) -> dict: + r = { + key: _clean_text(value) if isinstance(value, str) else value + for key, value in responses.items() + } + r["RFC"] = _normalize_id(r.get("RFC")) + r["CURP"] = _normalize_id(r.get("CURP")) + + fecha_nac = _build_date( + r.get("CUMPLE_ANIO", "0000"), + r.get("CUMPLE_MES", "01"), + r.get("CUMPLE_DIA", "01"), + ) + fecha_ini = _build_date( + r.get("INICIO_ANIO", "0000"), + r.get("INICIO_MES", "01"), + r.get("INICIO_DIA", "01"), + ) + + estado_nacimiento = _resolve_other(r, "ESTADO_NACIMIENTO", "ESTADO_NACIMIENTO_OTRO") + ciudad_residencia = _resolve_other(r, "CIUDAD_RESIDENCIA", "CIUDAD_RESIDENCIA_OTRO") + emergencia_relacion = _resolve_other( + r, "EMERGENCIA_RELACION", "EMERGENCIA_RELACION_OTRA" + ) + + referencias = [] + for idx in range(1, 4): + nombre = r.get(f"REF{idx}_NOMBRE", "N/A") + telefono = r.get(f"REF{idx}_TELEFONO", "N/A") + relacion = _resolve_other(r, f"REF{idx}_TIPO", f"REF{idx}_TIPO_OTRA") + referencias.append( + {"nombre": nombre, "telefono": telefono, "relacion": relacion} + ) + + num_ext_texto = numero_a_texto(r.get("NUM_EXTERIOR", ""), r.get("NUM_INTERIOR", "")) + sucursal_id = MAPA_SUCURSALES.get(r.get("SUCURSAL"), "otra_sucursal") + + curp_val = r.get("CURP", "XXXX000000") + curp_prefijo = curp_val[:4] if len(curp_val) >= 4 else "XXXX" + try: + fecha_inicio_dt = datetime.strptime(fecha_ini, "%Y-%m-%d") + numero_empleado = f"{curp_prefijo}{fecha_inicio_dt.strftime('%y%m%d')}" + except Exception: + fecha_compacta = fecha_ini.replace("-", "") + sufijo = ( + fecha_compacta[-6:] if len(fecha_compacta) >= 6 else fecha_compacta or "N/A" + ) + numero_empleado = f"{curp_prefijo}{sufijo}" + + now = datetime.now() + start_ts = meta.get("start_ts", now.timestamp()) + metadata_block = { + "telegram_id": meta.get("telegram_id"), + "username": meta.get("username"), + "first_name": meta.get("first_name"), + "chat_id": meta.get("telegram_id"), + "bot_version": BOT_VERSION, + "fecha_registro": now.isoformat(), + "duracion_segundos": round(now.timestamp() - start_ts, 2), + "mensajes_totales": meta.get("msg_count", 0), + } + + payload = { + "candidato": { + "nombre_preferido": r.get("NOMBRE_SALUDO"), + "nombre_oficial": r.get("NOMBRE_COMPLETO"), + "apellido_paterno": r.get("APELLIDO_PATERNO"), + "apellido_materno": r.get("APELLIDO_MATERNO"), + "fecha_nacimiento": fecha_nac, + "rfc": r.get("RFC"), + "curp": r.get("CURP"), + "lugar_nacimiento": estado_nacimiento, + }, + "contacto": { + "email": r.get("CORREO"), + "celular": r.get("CELULAR"), + }, + "domicilio": { + "calle": r.get("CALLE"), + "num_ext": r.get("NUM_EXTERIOR"), + "num_int": r.get("NUM_INTERIOR"), + "num_ext_texto": num_ext_texto, + "colonia": r.get("COLONIA"), + "cp": r.get("CODIGO_POSTAL"), + "ciudad": ciudad_residencia, + "estado": "Coahuila de Zaragoza", + }, + "laboral": { + "rol_id": _clean_text(r.get("ROL")).lower() or "n/a", + "sucursal_id": sucursal_id, + "fecha_inicio": fecha_ini, + "numero_empleado": numero_empleado, + }, + "referencias": referencias, + "emergencia": { + "nombre": r.get("EMERGENCIA_NOMBRE"), + "telefono": r.get("EMERGENCIA_TEL"), + "relacion": emergencia_relacion, + }, + "metadata": metadata_block, + } + + return payload + + +def _send_webhook(url: str, payload: dict): + """Sends a POST request to a webhook.""" + if not url: + logging.warning("No webhook URL provided.") + return False + try: + headers = {"Content-Type": "application/json"} + res = requests.post(url, json=payload, headers=headers, timeout=20) + res.raise_for_status() + logging.info(f"Webhook sent successfully to: {url}") + return True + except Exception as e: + logging.error(f"Error sending webhook to {url}: {e}") + return False + + +def _convert_to_time(time_str: str): + """Converts a string like '10:00 AM' to a datetime.time object.""" + if not time_str or not isinstance(time_str, str): + return None + try: + # Handle 'Todo el día' or other non-time strings + if ":" not in time_str: + return None + return datetime.strptime(time_str, "%I:%M %p").time() + except ValueError: + logging.warning(f"Could not parse time string: {time_str}") + return None + + +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 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"), + } + + 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: + 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 (vanity_hr.horario_empleadas) + # Disabled temporarily as DB is removed + if SessionVanityHr: + logging.warning( + "SessionVanityHr is present but DB logic is disabled in finalizer." + ) + + return True + + +def _finalize_onboarding(telegram_id: int, data: dict): + """Finalizes the onboarding flow using declarative responses.""" + logging.info("Finalizing 'onboarding' flow for telegram_id: %s", telegram_id) + + responses = _extract_responses(data) + meta = data.get("_meta", {}) + + if not responses: + logging.error("No responses captured for onboarding flow.") + return { + "success": False, + "message_error": "⚠️ No pude leer tus respuestas. Por favor, intenta /registro de nuevo.", + "reply_markup": main_actions_keyboard(), + } + + payload = _prepare_onboarding_payload(responses, meta) + meta_for_db = dict(meta) + meta_for_db.update( + { + "msg_count": meta.get("msg_count", 0), + "bot_version": BOT_VERSION, + } + ) + meta_for_db.setdefault("telegram_id", telegram_id) + meta_for_db.setdefault("flow_name", data.get("flow_name")) + metadata_block = payload.get("metadata", {}) + if metadata_block: + meta_for_db.setdefault("fecha_registro", metadata_block.get("fecha_registro")) + meta_for_db["duracion_segundos"] = metadata_block.get("duracion_segundos") + + enviados = 0 + for url in WEBHOOK_ONBOARDING_URLS: + if _send_webhook(url, payload): + enviados += 1 + + try: + db_ok = register_user({"meta": meta_for_db, **payload}) + except Exception as exc: + logging.error("Error registrando usuaria en DB: %s", exc) + db_ok = False + + if not db_ok: + return { + "success": False, + "message_error": ( + "⚠️ Hubo un problema guardando tu registro. RH ya fue notificado; inténtalo más tarde." + ), + "reply_markup": main_actions_keyboard(), + } + + if enviados: + success_message = ( + "✅ *¡Registro exitoso!*\n\n" + "Bienvenida a la familia Soul/Vanity. Tu contrato se está generando y te avisaremos pronto.\n" + "¡Nos vemos el primer día! ✨" + ) + else: + success_message = ( + "⚠️ Se guardaron tus datos, pero no pude notificar al webhook. RH lo revisará manualmente.\n" + "Si necesitas confirmar algo, contacta a tu manager." + ) + + return { + "success": True, + "message_success": success_message, + "reply_markup": main_actions_keyboard(), + } + + +# Mapping of flow names to finalization functions +FINALIZATION_MAP = { + "horario": _finalize_horario, + "onboarding": _finalize_onboarding, +} + + +async def finalize_flow(update, context): + """Generic function to finalize a conversation flow.""" + flow_name = context.user_data.get("flow_name") + telegram_id = update.effective_user.id + + if not flow_name: + logging.error("finalize_flow called without a flow_name in user_data.") + return + + finalizer_func = FINALIZATION_MAP.get(flow_name) + if not finalizer_func: + logging.warning(f"No finalizer function found for flow: {flow_name}") + await update.message.reply_text("Flujo completado (sin acción final definida).") + return + + # The final answer needs to be saved first + current_state_key = context.user_data.get("current_state") + if current_state_key is not None: + flow_definition_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "conv-flows", + f"{flow_name}.json", + ) + with open(flow_definition_path, "r") as f: + flow = json.load(f) + current_step = next( + (step for step in flow["steps"] if step["state"] == current_state_key), None + ) + if current_step and current_step.get("type") != "info": + variable_name = current_step.get("variable") + if variable_name: + context.user_data[variable_name] = update.message.text + + result = finalizer_func(telegram_id, context.user_data) + + reply_markup = None + if isinstance(result, dict): + success = result.get("success", True) + message = ( + result.get("message_success") if success else result.get("message_error") + ) + reply_markup = result.get("reply_markup") + else: + success = bool(result) + message = None + + defaults = DEFAULT_MESSAGES.get(flow_name, DEFAULT_MESSAGES["default"]) + if not message: + message = defaults["success"] if success else defaults["error"] + + await update.message.reply_text(message, reply_markup=reply_markup) + context.user_data.clear() diff --git a/app/modules/flow_builder.py b/app/modules/flow_builder.py new file mode 100644 index 0000000..9d02f39 --- /dev/null +++ b/app/modules/flow_builder.py @@ -0,0 +1,286 @@ +import ast +import json +import logging +import os +from datetime import datetime +from functools import partial + +from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update +from telegram.ext import ( + CommandHandler, + ContextTypes, + ConversationHandler, + MessageHandler, + filters, +) + +from modules.database import chat_id_exists +from modules.logger import log_request +from modules.ui import main_actions_keyboard + +from .finalizer import finalize_flow + + +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, + ) + + +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 + + +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 + + +def _ensure_meta(context: ContextTypes.DEFAULT_TYPE, flow_name: str, user): + meta = { + "telegram_id": user.id, + "username": user.username or "N/A", + "first_name": user.first_name, + "full_name": user.full_name, + "flow_name": flow_name, + "start_ts": datetime.now().timestamp(), + "msg_count": 0, + } + context.user_data["_meta"] = meta + + +async def _check_guards(flow: dict, update: Update) -> bool: + guards = flow.get("guards", []) + if not guards: + return True + + user = update.effective_user + if "require_new_user" in guards and chat_id_exists(user.id): + await update.message.reply_text( + "👩‍💼 Hola de nuevo. Ya tienes un registro activo en nuestro sistema.\n\n" + "Si crees que es un error o necesitas hacer cambios, contacta a tu manager o a RH directamente.", + reply_markup=main_actions_keyboard(is_registered=True), + ) + return False + + return True + + +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: + if state_key == -1: + continue + callback = partial(generic_callback, flow=flow) + states[state_key] = [MessageHandler(filters.TEXT & ~filters.COMMAND, callback)] + + command_names = flow.get("commands") or [flow["flow_name"]] + entry_points = [ + CommandHandler(name, partial(start_flow, flow=flow)) for name in command_names + ] + return ConversationHandler( + entry_points=entry_points, + states=states, + fallbacks=[CommandHandler("cancelar", end_cancel)], + 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) + meta = context.user_data.get("_meta") + if meta: + meta["msg_count"] = meta.get("msg_count", 0) + 1 + context.user_data["_meta"] = meta + 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): + user = update.effective_user + message_text = update.message.text if update.message else "" + log_request(user.id, user.username, flow["flow_name"], message_text) + + if not await _check_guards(flow, update): + return ConversationHandler.END + + context.user_data.clear() + context.user_data["flow_name"] = flow["flow_name"] + _ensure_meta(context, flow["flow_name"], user) + + first_state = flow["steps"][0]["state"] + return await _go_to_state(update, context, flow, first_state) + + +def load_flows(): + flow_handlers = [] + flow_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "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." + ) + except json.JSONDecodeError as e: + logging.error(f"Error decoding JSON from {filename}: {e}") + except Exception as e: + logging.error(f"Error creating handler for {filename}: {e}") + return flow_handlers diff --git a/app/modules/logger.py b/app/modules/logger.py new file mode 100644 index 0000000..4ed8f5b --- /dev/null +++ b/app/modules/logger.py @@ -0,0 +1,12 @@ +import logging +from modules.database import SessionUsersAlma + +def log_request(telegram_id, username, command, message): + if not SessionUsersAlma: + # DB not configured, just log to console/file + logging.info(f"REQ: {command} from {username} ({telegram_id}): {message}") + return + + # DB logic removed as models are not available + logging.info(f"REQ: {command} from {username} ({telegram_id}): {message}") + diff --git a/app/modules/onboarding.py b/app/modules/onboarding.py new file mode 100644 index 0000000..5d28b3c --- /dev/null +++ b/app/modules/onboarding.py @@ -0,0 +1,6 @@ +"""Legacy onboarding flow. + +The interactive onboarding conversation is now defined declaratively in +conv-flows/onboarding.json and handled by modules.flow_builder + modules.finalizer. +This stub remains so historical imports continue to resolve if referenced elsewhere. +""" diff --git a/app/modules/rh_requests.py b/app/modules/rh_requests.py new file mode 100644 index 0000000..6b49e0f --- /dev/null +++ b/app/modules/rh_requests.py @@ -0,0 +1,577 @@ +import os +import requests +import secrets +import string +from datetime import datetime, date, timedelta +from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update +from telegram.ext import ( + CommandHandler, + ContextTypes, + ConversationHandler, + MessageHandler, + filters, +) +from modules.logger import log_request +from modules.ui import main_actions_keyboard +from modules.ai import classify_reason + + +# IDs cortos para correlación y trazabilidad +def _short_id(length: int = 11) -> str: + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +# Helpers de webhooks +def _get_webhook_list(env_name: str) -> list: + raw = os.getenv(env_name, "") + return [w.strip() for w in raw.split(",") if w.strip()] + + +def _send_webhooks(urls: list, payload: dict): + enviados = 0 + for url in urls: + try: + res = requests.post(url, json=payload, timeout=15) + res.raise_for_status() + enviados += 1 + except Exception as e: + print(f"[webhook] Error enviando a {url}: {e}") + return enviados + + +# Estados de conversación +( + INICIO_DIA, + INICIO_MES, + INICIO_ANIO, + FIN_DIA, + FIN_MES, + FIN_ANIO, + PERMISO_CUANDO, + PERMISO_ANIO, + HORARIO, + MOTIVO, +) = range(10) + +# Teclados de apoyo +MESES = [ + "Enero", + "Febrero", + "Marzo", + "Abril", + "Mayo", + "Junio", + "Julio", + "Agosto", + "Septiembre", + "Octubre", + "Noviembre", + "Diciembre", +] +TECLADO_MESES = ReplyKeyboardMarkup( + [MESES[i : i + 3] for i in range(0, 12, 3)], + one_time_keyboard=True, + resize_keyboard=True, +) +MESES_MAP = {nombre.lower(): idx + 1 for idx, nombre in enumerate(MESES)} +ANIO_ACTUAL = datetime.now().year +TECLADO_ANIOS = ReplyKeyboardMarkup( + [[str(ANIO_ACTUAL), str(ANIO_ACTUAL + 1)]], + one_time_keyboard=True, + resize_keyboard=True, +) +TECLADO_PERMISO_CUANDO = ReplyKeyboardMarkup( + [["Hoy", "Mañana"], ["Pasado mañana", "Fecha específica"]], + one_time_keyboard=True, + resize_keyboard=True, +) + + +def _parse_dia(texto: str) -> int: + try: + dia = int(texto) + if 1 <= dia <= 31: + return dia + except Exception: + pass + return 0 + + +def _parse_mes(texto: str) -> int: + return MESES_MAP.get(texto.strip().lower(), 0) + + +def _parse_anio(texto: str) -> int: + try: + return int(texto) + except Exception: + return 0 + + +def _build_dates(datos: dict) -> dict: + """Construye fechas ISO; si fin < inicio, se ajusta a inicio.""" + try: + inicio_anio = datos.get("inicio_anio", ANIO_ACTUAL) + inicio = date(inicio_anio, datos["inicio_mes"], datos["inicio_dia"]) + fin_dia = datos.get("fin_dia", datos.get("inicio_dia")) + fin_mes = datos.get("fin_mes", datos.get("inicio_mes")) + fin_anio = datos.get("fin_anio", datos.get("inicio_anio", inicio.year)) + # Ajuste automático para cruces de año (ej: 28 Dic -> 15 Ene) + if fin_anio == inicio.year and ( + fin_mes < inicio.month or (fin_mes == inicio.month and fin_dia < inicio.day) + ): + fin_anio = inicio.year + 1 + fin = date(fin_anio, fin_mes, fin_dia) + if fin < inicio: + fin = inicio + return {"inicio": inicio, "fin": fin} + except Exception: + return {} + + +def _calculate_vacation_metrics_from_dates(fechas: dict) -> dict: + today = date.today() + inicio = fechas.get("inicio") + fin = fechas.get("fin") + if not inicio or not fin: + return {"dias_totales": 0, "dias_anticipacion": 0} + dias_totales = (fin - inicio).days + 1 + dias_anticipacion = (inicio - today).days + return { + "dias_totales": dias_totales, + "dias_anticipacion": dias_anticipacion, + "fechas_calculadas": {"inicio": inicio.isoformat(), "fin": fin.isoformat()}, + } + + +def _fmt_fecha(fecha_iso: str) -> str: + if not fecha_iso: + return "N/A" + try: + return fecha_iso.split("T")[0] + except Exception: + return fecha_iso + + +# --- Vacaciones --- +async def start_vacaciones(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + log_request(user.id, user.username, "vacaciones", update.message.text) + context.user_data.clear() + context.user_data["tipo"] = "VACACIONES" + await update.message.reply_text( + "🌴 **Solicitud de Vacaciones**\n\nVamos a registrar tu descanso. ¿Qué *día* inicia? (número, ej: 10)", + reply_markup=ReplyKeyboardRemove(), + ) + return INICIO_DIA + + +# --- Permiso --- +async def start_permiso(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + log_request(user.id, user.username, "permiso", update.message.text) + context.user_data.clear() + context.user_data["tipo"] = "PERMISO" + await update.message.reply_text( + "⏱️ **Solicitud de Permiso**\n\n¿Para cuándo lo necesitas?", + reply_markup=TECLADO_PERMISO_CUANDO, + ) + return PERMISO_CUANDO + + +# --- Selección de año / cuando --- +async def recibir_inicio_anio( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> int: + anio = _parse_anio(update.message.text) + available_years = context.user_data.get( + "available_years", [ANIO_ACTUAL, ANIO_ACTUAL + 1] + ) + if anio not in available_years: + teclado = ReplyKeyboardMarkup( + [[str(y) for y in available_years]], + one_time_keyboard=True, + resize_keyboard=True, + ) + await update.message.reply_text("Elige un año válido.", reply_markup=teclado) + return INICIO_ANIO + context.user_data["inicio_anio"] = anio + await update.message.reply_text( + "¿Qué *día* termina tu descanso?", reply_markup=ReplyKeyboardRemove() + ) + return FIN_DIA + + +async def recibir_cuando_permiso( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> int: + texto = update.message.text.strip().lower() + hoy = date.today() + offset_map = { + "hoy": 0, + "mañana": 1, + "manana": 1, + "pasado mañana": 2, + "pasado manana": 2, + } + if texto in offset_map: + delta = offset_map[texto] + fecha = hoy.fromordinal(hoy.toordinal() + delta) + context.user_data["inicio_anio"] = fecha.year + context.user_data["fin_anio"] = fecha.year + context.user_data["inicio_dia"] = fecha.day + context.user_data["inicio_mes"] = fecha.month + context.user_data["fin_dia"] = fecha.day + context.user_data["fin_mes"] = fecha.month + await update.message.reply_text( + "¿Cuál es el horario? Ej: `09:00-11:00` o `Todo el día`.", + reply_markup=ReplyKeyboardRemove(), + ) + return HORARIO + if "fecha" in texto: + await update.message.reply_text( + "¿Para qué año es el permiso? (elige el actual o el siguiente)", + reply_markup=TECLADO_ANIOS, + ) + return PERMISO_ANIO + await update.message.reply_text( + "Elige una opción: Hoy, Mañana, Pasado mañana o Fecha específica.", + reply_markup=TECLADO_PERMISO_CUANDO, + ) + return PERMISO_CUANDO + + +async def recibir_anio_permiso( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> int: + anio = _parse_anio(update.message.text) + if anio not in (ANIO_ACTUAL, ANIO_ACTUAL + 1): + await update.message.reply_text( + "Elige el año del teclado (actual o siguiente).", reply_markup=TECLADO_ANIOS + ) + return PERMISO_ANIO + context.user_data["inicio_anio"] = anio + context.user_data["fin_anio"] = anio + if "inicio_dia" in context.user_data: + await update.message.reply_text( + "¿Qué *día* termina?", reply_markup=ReplyKeyboardRemove() + ) + return FIN_DIA + await update.message.reply_text( + "¿En qué *día* inicia el permiso? (número, ej: 12)", + reply_markup=ReplyKeyboardRemove(), + ) + return INICIO_DIA + + +# --- Captura de fechas --- +async def recibir_inicio_dia(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + dia = _parse_dia(update.message.text) + if not dia: + await update.message.reply_text( + "Necesito un número de día válido (1-31). Intenta de nuevo." + ) + return INICIO_DIA + context.user_data["inicio_dia"] = dia + await update.message.reply_text("¿De qué *mes* inicia?", reply_markup=TECLADO_MESES) + return INICIO_MES + + +async def recibir_inicio_mes(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + mes = _parse_mes(update.message.text) + if not mes: + await update.message.reply_text( + "Elige un mes del teclado o escríbelo igual que aparece.", + reply_markup=TECLADO_MESES, + ) + return INICIO_MES + context.user_data["inicio_mes"] = mes + if context.user_data.get("tipo") == "VACACIONES": + # Calcular años disponibles basados en fecha y reglas + today = date.today() + max_date = today + timedelta(days=45) + dia = context.user_data["inicio_dia"] + try: + current_year_date = date(ANIO_ACTUAL, mes, dia) + next_year_date = date(ANIO_ACTUAL + 1, mes, dia) + except ValueError: + await update.message.reply_text( + "Fecha inválida. Elige un día válido para el mes." + ) + return INICIO_DIA + years_to_show = [] + if current_year_date >= today and current_year_date <= max_date: + years_to_show.append(ANIO_ACTUAL) + if next_year_date >= today and next_year_date <= max_date: + years_to_show.append(ANIO_ACTUAL + 1) + if not years_to_show: + years_to_show = [ANIO_ACTUAL, ANIO_ACTUAL + 1] # Fallback + teclado_anios = ReplyKeyboardMarkup( + [[str(y) for y in years_to_show]], + one_time_keyboard=True, + resize_keyboard=True, + ) + context.user_data["available_years"] = years_to_show + await update.message.reply_text( + "¿De qué *año* inicia?", reply_markup=teclado_anios + ) + return INICIO_ANIO + + context.user_data.setdefault("inicio_anio", ANIO_ACTUAL) + context.user_data.setdefault( + "fin_anio", context.user_data.get("inicio_anio", ANIO_ACTUAL) + ) + + try: + inicio_candidato = date( + context.user_data["inicio_anio"], mes, context.user_data["inicio_dia"] + ) + if inicio_candidato < date.today(): + await update.message.reply_text( + "Esa fecha ya pasó este año. ¿Para qué año la agendamos?", + reply_markup=TECLADO_ANIOS, + ) + return PERMISO_ANIO + except Exception: + pass + + await update.message.reply_text( + "¿Qué *día* termina?", reply_markup=ReplyKeyboardRemove() + ) + return FIN_DIA + + +async def recibir_fin_dia(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + dia = _parse_dia(update.message.text) + if not dia: + await update.message.reply_text("Día inválido. Dame un número de 1 a 31.") + return FIN_DIA + context.user_data["fin_dia"] = dia + await update.message.reply_text( + "¿De qué *mes* termina?", reply_markup=TECLADO_MESES + ) + return FIN_MES + + +async def recibir_fin_mes(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + mes = _parse_mes(update.message.text) + if not mes: + await update.message.reply_text( + "Elige un mes válido.", reply_markup=TECLADO_MESES + ) + return FIN_MES + context.user_data["fin_mes"] = mes + + if context.user_data.get("tipo") == "PERMISO": + context.user_data.setdefault( + "fin_anio", context.user_data.get("inicio_anio", ANIO_ACTUAL) + ) + await update.message.reply_text( + "¿Cuál es el horario? Ej: `09:00-11:00` o `Todo el día`.", + reply_markup=ReplyKeyboardRemove(), + ) + return HORARIO + + await update.message.reply_text( + "¿De qué *año* termina tu descanso?", reply_markup=TECLADO_ANIOS + ) + return FIN_ANIO + + +async def recibir_fin_anio(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + anio = _parse_anio(update.message.text) + if anio not in (ANIO_ACTUAL, ANIO_ACTUAL + 1): + await update.message.reply_text( + "Elige el año del teclado (actual o siguiente).", reply_markup=TECLADO_ANIOS + ) + return FIN_ANIO + context.user_data["fin_anio"] = anio + await update.message.reply_text( + "Entendido. ¿Cuál es el motivo o comentario adicional?", + reply_markup=ReplyKeyboardRemove(), + ) + return MOTIVO + + +async def recibir_horario(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data["horario"] = update.message.text.strip() + await update.message.reply_text( + "Entendido. ¿Cuál es el motivo o comentario adicional?", + reply_markup=ReplyKeyboardRemove(), + ) + return MOTIVO + + +# --- Motivo y cierre --- +async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + motivo = update.message.text + datos = context.user_data + user = update.effective_user + + fechas = _build_dates(datos) + if not fechas: + await update.message.reply_text( + "🤔 No entendí las fechas. Por favor, inicia otra vez con /vacaciones o /permiso." + ) + return ConversationHandler.END + + payload = { + "record_id": _short_id(), + "solicitante": { + "id_telegram": user.id, + "nombre": user.full_name, + "username": user.username, + }, + "tipo_solicitud": datos["tipo"], + "fechas": { + "inicio": fechas.get("inicio").isoformat() if fechas else None, + "fin": fechas.get("fin").isoformat() if fechas else None, + }, + "motivo_usuario": motivo, + "created_at": datetime.now().isoformat(), + } + + webhooks = [] + if datos["tipo"] == "PERMISO": + webhooks = _get_webhook_list("WEBHOOK_PERMISOS") + categoria = classify_reason(motivo) + payload["categoria_detectada"] = categoria + payload["horario"] = datos.get("horario", "N/A") + await update.message.reply_text(f"Categoría detectada → **{categoria}** 🚨") + + elif datos["tipo"] == "VACACIONES": + webhooks = _get_webhook_list("WEBHOOK_VACACIONES") + metrics = _calculate_vacation_metrics_from_dates(fechas) + + if metrics["dias_totales"] > 0: + payload["metricas"] = metrics + + dias = metrics["dias_totales"] + anticipacion = metrics.get("dias_anticipacion", 0) + if anticipacion < 0: + status = "RECHAZADO" + mensaje = ( + "🔴 No puedo agendar vacaciones en el pasado. Ajusta tus fechas." + ) + elif anticipacion > 45: + status = "RECHAZADO" + mensaje = ( + "🔴 Debes solicitar vacaciones con máximo 45 días de anticipación." + ) + elif dias < 6: + status = "RECHAZADO" + mensaje = f"🔴 {dias} días es un periodo muy corto. Las vacaciones deben ser de al menos 6 días." + elif dias > 30: + status = "RECHAZADO" + mensaje = "🔴 Las vacaciones no pueden exceder 30 días. Ajusta tus fechas, por favor." + elif 6 <= dias <= 11: + status = "APROBACION_ESPECIAL" + mensaje = f"🟠 Solicitud de {dias} días: requiere aprobación especial." + else: # 12-30 + status = "EN_ESPERA_APROBACION" + mensaje = f"🟡 Solicitud de {dias} días registrada. Queda en espera de aprobación." + + payload["status_inicial"] = status + await update.message.reply_text(mensaje) + else: + payload["status_inicial"] = "ERROR_FECHAS" + await update.message.reply_text( + "🤔 No entendí las fechas. Por favor, comparte día y mes otra vez con /vacaciones." + ) + + try: + enviados = _send_webhooks(webhooks, payload) if webhooks else 0 + tipo_solicitud_texto = "Permiso" if datos["tipo"] == "PERMISO" else "Vacaciones" + inicio_txt = _fmt_fecha(payload["fechas"]["inicio"]) + fin_txt = _fmt_fecha(payload["fechas"]["fin"]) + if datos["tipo"] == "PERMISO": + resumen = ( + "📝 Resumen enviado:\n" + f"- Fecha: {inicio_txt} a {fin_txt}\n" + f"- Horario: {payload.get('horario', 'N/A')}\n" + f"- Categoría: {payload.get('categoria_detectada', 'N/A')}\n" + f"- Motivo: {motivo}" + ) + else: + m = payload.get("metricas", {}) + resumen = ( + "📝 Resumen enviado:\n" + f"- Inicio: {inicio_txt}\n" + f"- Fin: {fin_txt}\n" + f"- Días totales: {m.get('dias_totales', 'N/A')}\n" + f"- Anticipación: {m.get('dias_anticipacion', 'N/A')} días\n" + f"- Estatus inicial: {payload.get('status_inicial', 'N/A')}" + ) + if enviados > 0: + await update.message.reply_text( + f"✅ Solicitud de *{tipo_solicitud_texto}* enviada a tu Manager.\n\n{resumen}", + reply_markup=main_actions_keyboard(), + ) + else: + await update.message.reply_text( + f"⚠️ No hay webhook configurado o falló el envío. RH lo revisará.\n\n{resumen}", + reply_markup=main_actions_keyboard(), + ) + except Exception as e: + print(f"Error enviando webhook: {e}") + await update.message.reply_text( + "⚠️ Error enviando la solicitud.", reply_markup=main_actions_keyboard() + ) + + return ConversationHandler.END + + +async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + await update.message.reply_text( + "Solicitud cancelada. ⏸️\nPuedes volver a iniciar con /vacaciones o /permiso, o ir al menú con /start.", + reply_markup=main_actions_keyboard(), + ) + return ConversationHandler.END + + +# Handlers separados pero comparten lógica +vacaciones_handler = ConversationHandler( + entry_points=[CommandHandler("vacaciones", start_vacaciones)], + states={ + INICIO_DIA: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_dia) + ], + INICIO_MES: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_mes) + ], + INICIO_ANIO: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_anio) + ], + FIN_DIA: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_dia)], + FIN_MES: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_mes)], + FIN_ANIO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_anio)], + MOTIVO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_motivo_fin)], + }, + fallbacks=[CommandHandler("cancelar", cancelar)], + allow_reentry=True, +) + +permiso_handler = ConversationHandler( + entry_points=[CommandHandler("permiso", start_permiso)], + states={ + PERMISO_CUANDO: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_cuando_permiso) + ], + PERMISO_ANIO: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_anio_permiso) + ], + INICIO_DIA: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_dia) + ], + INICIO_MES: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_mes) + ], + FIN_DIA: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_dia)], + FIN_MES: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_mes)], + HORARIO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_horario)], + MOTIVO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_motivo_fin)], + }, + fallbacks=[CommandHandler("cancelar", cancelar)], + allow_reentry=True, +) diff --git a/app/modules/ui.py b/app/modules/ui.py new file mode 100644 index 0000000..2b9b688 --- /dev/null +++ b/app/modules/ui.py @@ -0,0 +1,18 @@ +from telegram import ReplyKeyboardMarkup + + +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(["/registro"]) + + keyboard.extend([ + ["/vacaciones", "/permiso"], + ["/links", "/start"], + ]) + + return ReplyKeyboardMarkup( + keyboard, + resize_keyboard=True, + ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bfc0b07 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +version: "3.8" + +services: + bot: + build: . + image: marcogll/vanessa-bot:1.8 + container_name: vanessa_bot + restart: always + env_file: + - .env + environment: + - MYSQL_HOST=db + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + depends_on: + db: + condition: service_healthy + volumes: + - .:/app + + db: + image: mysql:8.0 + container_name: vanessa_db + restart: always + environment: + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h localhost -u${MYSQL_USER} -p${MYSQL_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s + volumes: + - ./db/init:/docker-entrypoint-initdb.d + - mysql_data:/var/lib/mysql + ports: + - "3306:3306" + +volumes: + mysql_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dd46363 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +python-telegram-bot +python-dotenv +requests + +google-generativeai +openai \ No newline at end of file diff --git a/start_bot.sh b/start_bot.sh new file mode 100755 index 0000000..569e700 --- /dev/null +++ b/start_bot.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +echo "Starting Vanessa Bot..." + +# Check for .env file +if [ ! -f .env ]; then + echo "WARNING: .env file not found." + echo "Please create one by copying .env.example and filling in your Telegram Token and Webhook URLs." + echo "cp .env.example .env" + # Optionally, exit or prompt user + # exit 1 +fi + +echo "Choose how to start the bot:" +echo "1) Run with Docker Compose (recommended for production/development)" +echo "2) Run directly with Python (for local development/testing)" +read -p "Enter choice (1 or 2): " choice + +case $choice in + 1) + echo "Running with Docker Compose..." + docker-compose up --build --detach + echo "Bot started in detached mode. Use 'docker-compose logs -f' to see logs." + ;; + 2) + echo "Running directly with Python..." + # Check for virtual environment and activate it if found + if [ -d "venv" ]; then + source venv/bin/activate + echo "Activated virtual environment: venv" + elif [ -d ".venv" ]; then + source .venv/bin/activate + echo "Activated virtual environment: .venv" + else + echo "No virtual environment found. Consider creating one: python3 -m venv venv" + echo "Installing dependencies globally (not recommended without venv)..." + pip install -r requirements.txt + fi + + # Install dependencies if not already in venv + if [ ! -d "venv" ] && [ ! -d ".venv" ]; then + pip install -r requirements.txt + fi + + echo "Executing python app/main.py..." + python app/main.py + ;; + *) + echo "Invalid choice. Exiting." + exit 1 + ;; +esac + +echo "Bot startup script finished." \ No newline at end of file