first commit

This commit is contained in:
Marco Gallegos
2025-12-22 22:47:33 -06:00
commit 36b7154c6e
23 changed files with 2713 additions and 0 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.env
.env.bak
__pycache__/
.git/
.gitignore
.venv/
venv/
*.pyc
*.pyo
*.pyd
*.log
dist/
build/
node_modules/
README.md
Vanessa.md

49
.env.example Normal file
View File

@@ -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

48
.gitignore vendored Normal file
View File

@@ -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/

18
Dockerfile Normal file
View File

@@ -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"]

95
Readme.md Normal file
View File

@@ -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.

0
app/__init__.py Normal file
View File

107
app/conv-flows/horario.json Normal file
View File

@@ -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."
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

161
app/main.py Normal file
View File

@@ -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()

0
app/modules/__init__.py Normal file
View File

55
app/modules/ai.py Normal file
View File

@@ -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

31
app/modules/database.py Normal file
View File

@@ -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

499
app/modules/finalizer.py Normal file
View File

@@ -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()

286
app/modules/flow_builder.py Normal file
View File

@@ -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, "<condition>", "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

12
app/modules/logger.py Normal file
View File

@@ -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}")

View File

@@ -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.
"""

577
app/modules/rh_requests.py Normal file
View File

@@ -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,
)

18
app/modules/ui.py Normal file
View File

@@ -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,
)

42
docker-compose.yml Normal file
View File

@@ -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:

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
python-telegram-bot
python-dotenv
requests
google-generativeai
openai

54
start_bot.sh Executable file
View File

@@ -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."