mirror of
https://github.com/marcogll/vanessa_bot_vanity.git
synced 2026-01-13 05:15:15 +00:00
first commit
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
49
.env.example
Normal 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
48
.gitignore
vendored
Normal 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
18
Dockerfile
Normal 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
95
Readme.md
Normal 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
0
app/__init__.py
Normal file
107
app/conv-flows/horario.json
Normal file
107
app/conv-flows/horario.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
95
app/conv-flows/leave_request.json
Normal file
95
app/conv-flows/leave_request.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
458
app/conv-flows/onboarding.json
Normal file
458
app/conv-flows/onboarding.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
80
app/conv-flows/vacations.json
Normal file
80
app/conv-flows/vacations.json
Normal 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
161
app/main.py
Normal 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
0
app/modules/__init__.py
Normal file
55
app/modules/ai.py
Normal file
55
app/modules/ai.py
Normal 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
31
app/modules/database.py
Normal 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
499
app/modules/finalizer.py
Normal 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
286
app/modules/flow_builder.py
Normal 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
12
app/modules/logger.py
Normal 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}")
|
||||
|
||||
6
app/modules/onboarding.py
Normal file
6
app/modules/onboarding.py
Normal 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
577
app/modules/rh_requests.py
Normal 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
18
app/modules/ui.py
Normal 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
42
docker-compose.yml
Normal 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
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
python-telegram-bot
|
||||
python-dotenv
|
||||
requests
|
||||
|
||||
google-generativeai
|
||||
openai
|
||||
54
start_bot.sh
Executable file
54
start_bot.sh
Executable 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."
|
||||
Reference in New Issue
Block a user