feat: Implement detailed onboarding data capture, refactor HR request date processing, and enhance bot command menu.

This commit is contained in:
Marco Gallegos
2025-12-14 22:09:23 -06:00
parent cbfcee557a
commit 29b2605072
4 changed files with 324 additions and 100 deletions

View File

@@ -1,6 +1,6 @@
# 🤖 Vanessa Bot Asistente de RH para Vanity
Vanessa es un bot de Telegram escrito en Python que automatiza procesos internos de Recursos Humanos en Vanity. Su objetivo es eliminar fricción operativa: onboarding, solicitudes de RH e impresión de documentos, todo orquestado desde Telegram y conectado a flujos de n8n o servicios de correo.
Vanessa es un bot de Telegram escrito en Python que automatiza procesos internos de Recursos Humanos en Vanity. Su objetivo es eliminar fricción operativa: onboarding y solicitudes de RH, todo orquestado desde Telegram y conectado a flujos de n8n o servicios de correo.
Este repositorio está pensado como **proyecto Python profesional**, modular y listo para correr 24/7 en producción.
@@ -11,11 +11,10 @@ Este repositorio está pensado como **proyecto Python profesional**, modular y l
Vanessa no es un chatbot genérico: es una interfaz conversacional para procesos reales de negocio.
- Onboarding completo de nuevas socias (`/welcome`)
- Envío de archivos a impresión por correo electrónico (`/print`)
- Solicitud de vacaciones (`/vacaciones`)
- Solicitud de permisos por horas (`/permiso`)
Cada flujo es un módulo independiente, y los datos se envían a **webhooks de n8n** o se procesan directamente, como en el caso de la impresión.
Cada flujo es un módulo independiente, y los datos se envían a **webhooks de n8n**.
---
@@ -36,7 +35,6 @@ vanity_bot/
├── __init__.py
├── database.py # Módulo de conexión a la base de datos
├── onboarding.py # Flujo /welcome (onboarding RH)
├── printer.py # Flujo /print (impresión por email)
└── rh_requests.py # /vacaciones y /permiso
```
@@ -52,8 +50,8 @@ TELEGRAM_TOKEN=TU_TOKEN_AQUI
# --- WEBHOOKS N8N ---
WEBHOOK_ONBOARDING=https://... # Alias aceptado: WEBHOOK_CONTRATO
WEBHOOK_PRINT=https://...
WEBHOOK_VACACIONES=https://...
WEBHOOK_PERMISOS=https://...
# --- DATABASE ---
# Usado por el servicio de la base de datos en docker-compose.yml
@@ -62,13 +60,6 @@ MYSQL_USER=user
MYSQL_PASSWORD=password
MYSQL_ROOT_PASSWORD=rootpassword
# --- SMTP PARA IMPRESIÓN ---
# Usado por el módulo de impresión para enviar correos
SMTP_SERVER=smtp.hostinger.com
SMTP_PORT=465
SMTP_USER=tu_email@dominio.com
SMTP_PASSWORD=tu_password_de_email
SMTP_RECIPIENT=email_destino@dominio.com # También puedes usar PRINTER_EMAIL
```
---
@@ -111,14 +102,12 @@ docker-compose down
### modules/onboarding.py
Flujo conversacional complejo que recolecta datos de nuevas empleadas y los envía a un webhook de n8n.
### modules/printer.py
- Recibe documentos o imágenes desde Telegram.
- Descarga el archivo de forma segura desde los servidores de Telegram.
- Se conecta a un servidor SMTP para enviar el archivo como un adjunto por correo electrónico a una dirección predefinida.
Incluye derivadas útiles: `num_ext_texto` (número en letras, con interior) y `numero_empleado` (primeras 4 del CURP + fecha de ingreso).
### modules/rh_requests.py
- Maneja solicitudes simples de RH (Vacaciones y Permisos) y las envía a un webhook de n8n.
- Vacaciones: pregunta año (actual o siguiente), día/mes de inicio y fin, calcula métricas y aplica semáforo automático.
- Permisos: ofrece accesos rápidos (hoy/mañana/pasado) o fecha específica (año actual/siguiente, día/mes), pide horario, clasifica motivo con IA y envía al webhook.
---
@@ -128,7 +117,6 @@ Flujo conversacional complejo que recolecta datos de nuevas empleadas y los env
- **Python como cerebro**: Lógica de negocio y orquestación.
- **Docker para despliegue**: Entornos consistentes y portátiles.
- **MySQL para persistencia**: Registro auditable de todas las interacciones.
- **SMTP para acciones directas**: Integración con sistemas estándar como el correo.
- **Modularidad total**: Cada habilidad es un componente independiente.
---

30
main.py
View File

@@ -5,13 +5,12 @@ from dotenv import load_dotenv
# Cargar variables de entorno antes de importar módulos que las usan
load_dotenv()
from telegram import Update, ReplyKeyboardMarkup
from telegram import Update, ReplyKeyboardMarkup, BotCommand
from telegram.constants import ParseMode
from telegram.ext import Application, Defaults, CommandHandler, ContextTypes
# --- IMPORTAR HABILIDADES ---
from modules.onboarding import onboarding_handler
from modules.printer import print_handler
from modules.rh_requests import vacaciones_handler, permiso_handler
from modules.database import log_request
# from modules.finder import finder_handler (Si lo creas después)
@@ -27,18 +26,38 @@ async def menu_principal(update: Update, context: ContextTypes.DEFAULT_TYPE):
log_request(user.id, user.username, "start", update.message.text)
texto = (
"👩‍💼 **Hola, soy Vanessa. ¿En qué puedo ayudarte hoy?**\n\n"
"Toca un comando en azul para lanzarlo rápido:"
"Comandos rápidos:\n"
"/welcome — Onboarding\n"
"/vacaciones — Solicitud de vacaciones\n"
"/permiso — Solicitud de permiso por horas\n\n"
"También tienes los botones rápidos abajo 👇"
)
teclado = ReplyKeyboardMarkup(
[["/welcome", "/print"], ["/vacaciones", "/permiso"]],
[["/welcome"], ["/vacaciones", "/permiso"]],
resize_keyboard=True
)
await update.message.reply_text(texto, reply_markup=teclado)
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", "Iniciar onboarding"),
BotCommand("vacaciones", "Solicitar vacaciones"),
BotCommand("permiso", "Solicitar permiso por horas"),
BotCommand("cancelar", "Cancelar flujo actual"),
])
def main():
# Configuración Global
defaults = Defaults(parse_mode=ParseMode.MARKDOWN)
app = Application.builder().token(TOKEN).defaults(defaults).build()
app = (
Application.builder()
.token(TOKEN)
.defaults(defaults)
.post_init(post_init)
.build()
)
# --- REGISTRO DE HABILIDADES ---
@@ -48,7 +67,6 @@ def main():
# 2. Habilidades Complejas (Conversaciones)
app.add_handler(onboarding_handler)
app.add_handler(print_handler)
app.add_handler(vacaciones_handler)
app.add_handler(permiso_handler)
# app.add_handler(finder_handler)

View File

@@ -67,6 +67,55 @@ def limpiar_texto_general(texto: str) -> str:
t = " ".join(texto.split())
return "N/A" if t == "0" else t
def _num_to_words_es_hasta_999(n: int) -> str:
"""Convierte un número (0-999) a texto en español sin acentos."""
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
if centenas == 100 and resto > 0:
prefijo = "ciento"
else:
prefijo = 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:
"""Devuelve un resumen textual del numero exterior (+ interior si aplica)."""
import re
texto_base = limpiar_texto_general(num_ext)
interior = limpiar_texto_general(num_int)
m = re.match(r"(\d+)", texto_base)
if not m:
return texto_base
numero = int(m.group(1))
en_letras = _num_to_words_es_hasta_999(numero)
if interior and interior.upper() != "N/A":
return f"{en_letras}, interior {interior}".strip()
return en_letras
# --- 4. TECLADOS DINÁMICOS ---
# Meses: Texto vs Valor
@@ -130,7 +179,8 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"telegram_id": user.id,
"username": user.username or "N/A",
"first_name": user.first_name,
"start_ts": datetime.now().timestamp()
"start_ts": datetime.now().timestamp(),
"msg_count": 0,
}
context.user_data["respuestas"] = {}
@@ -146,6 +196,9 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
async def manejar_flujo(update: Update, context: ContextTypes.DEFAULT_TYPE, estado_actual: int) -> int:
texto_recibido = update.message.text
respuesta_procesada = limpiar_texto_general(texto_recibido)
meta = context.user_data.get("metadata", {})
meta["msg_count"] = meta.get("msg_count", 0) + 1
context.user_data["metadata"] = meta
# --- LÓGICA DE PROCESAMIENTO ESPECÍFICA POR ESTADO ---
@@ -233,6 +286,9 @@ async def manejar_flujo(update: Update, context: ContextTypes.DEFAULT_TYPE, esta
async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
# Guardar última respuesta (Relación Emergencia)
context.user_data["respuestas"][EMERGENCIA_RELACION] = limpiar_texto_general(update.message.text)
meta = context.user_data.get("metadata", {})
meta["msg_count"] = meta.get("msg_count", 0) + 1
context.user_data["metadata"] = meta
await update.message.reply_text("¡Perfecto! 📝 Guardando tu expediente en el sistema... dame un momento.")
@@ -246,6 +302,10 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
except Exception:
fecha_nac = "ERROR_FECHA"
fecha_ini = "ERROR_FECHA"
# Derivados
num_ext_texto = numero_a_texto(r.get(NUM_EXTERIOR, ""), r.get(NUM_INTERIOR, ""))
curp_base = (r.get(CURP) or "").upper()
n_empleado = f"{(curp_base + 'XXXX')[:4]}{fecha_ini.replace('-', '')}"
# PAYLOAD ESTRUCTURADO PARA N8N
payload = {
@@ -267,6 +327,7 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"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": r.get(CIUDAD_RESIDENCIA),
@@ -275,7 +336,8 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"laboral": {
"rol_id": r.get(ROL).lower(), # partner, manager...
"sucursal_id": r.get(SUCURSAL), # plaza_cima, plaza_o
"fecha_inicio": fecha_ini
"fecha_inicio": fecha_ini,
"numero_empleado": n_empleado
},
"referencias": [
{"nombre": r.get(REF1_NOMBRE), "telefono": r.get(REF1_TELEFONO), "relacion": r.get(REF1_TIPO)},
@@ -291,7 +353,9 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"telegram_user": meta["username"],
"chat_id": meta["telegram_id"],
"bot_version": "welcome2soul_v2",
"fecha_registro": datetime.now().isoformat()
"fecha_registro": datetime.now().isoformat(),
"duracion_segundos": round(datetime.now().timestamp() - meta.get("start_ts", datetime.now().timestamp()), 2),
"mensajes_totales": meta.get("msg_count", 0)
}
}

View File

@@ -1,5 +1,4 @@
import os
import re
import requests
import uuid
from datetime import datetime, date
@@ -24,105 +23,224 @@ def _send_webhooks(urls: list, payload: dict):
print(f"[webhook] Error enviando a {url}: {e}")
return enviados
TIPO_SOLICITITUD, FECHAS, MOTIVO = range(3)
# Estados de conversación
(
VAC_ANIO,
INICIO_DIA,
INICIO_MES,
FIN_DIA,
FIN_MES,
PERMISO_CUANDO,
PERMISO_ANIO,
HORARIO,
MOTIVO,
) = range(9)
# Teclados de apoyo
MESES = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"]
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)
TECLADO_PERMISO_RAPIDO = ReplyKeyboardMarkup(
[["Hoy 09:00-11:00", "Hoy 15:00-18:00"], ["Mañana 09:00-11:00", "Mañana 15:00-18:00"], ["Otra fecha/horario"]],
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 _calculate_vacation_metrics(date_string: str) -> dict:
"""
Calcula métricas de vacaciones a partir de un texto.
Asume un formato como "10 al 15 de Octubre".
"""
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."""
year = datos.get("anio") or datetime.now().year
try:
inicio = date(year, 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 = date(year, 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()
current_year = today.year
# Mapeo de meses en español a número
meses = {
'enero': 1, 'febrero': 2, 'marzo': 3, 'abril': 4, 'mayo': 5, 'junio': 6,
'julio': 7, 'agosto': 8, 'septiembre': 9, 'octubre': 10, 'noviembre': 11, 'diciembre': 12
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()},
}
# Regex para "10 al 15 de Octubre"
match = re.search(r'(\d{1,2})\s*al\s*(\d{1,2})\s*de\s*(\w+)', date_string, re.IGNORECASE)
if not match:
return {"dias_totales": 0, "dias_anticipacion": 0}
start_day, end_day, month_str = match.groups()
start_day, end_day = int(start_day), int(end_day)
month = meses.get(month_str.lower())
if not month:
return {"dias_totales": 0, "dias_anticipacion": 0}
def _fmt_fecha(fecha_iso: str) -> str:
if not fecha_iso:
return "N/A"
try:
start_date = date(current_year, month, start_day)
# Si la fecha ya pasó este año, asumir que es del próximo año
if start_date < today:
start_date = date(current_year + 1, month, start_day)
end_date = date(start_date.year, month, end_day)
dias_totales = (end_date - start_date).days + 1
dias_anticipacion = (start_date - today).days
return {"dias_totales": dias_totales, "dias_anticipacion": dias_anticipacion, "fechas_calculadas": {"inicio": start_date.isoformat(), "fin": end_date.isoformat()}}
except ValueError:
return {"dias_totales": 0, "dias_anticipacion": 0}
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'
context.user_data["anio"] = ANIO_ACTUAL
await update.message.reply_text(
"🌴 **Solicitud de Vacaciones**\n\n¿Para qué fechas las necesitas?\nUsa el formato: `10 al 15 de Octubre`.",
reply_markup=TECLADO_MESES,
"🌴 **Solicitud de Vacaciones**\n\nUsaré el año actual. ¿En qué *día* inicia tu descanso? (número, ej: 10)",
reply_markup=ReplyKeyboardRemove(),
)
return FECHAS
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\nSelecciona o escribe el día y horario. Ej: `Jueves 15 09:00-11:00`.",
reply_markup=TECLADO_PERMISO_RAPIDO,
"⏱️ **Solicitud de Permiso**\n\n¿Para cuándo lo necesitas?",
reply_markup=TECLADO_PERMISO_CUANDO,
)
return FECHAS
return PERMISO_CUANDO
async def recibir_fechas(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
texto_fechas = update.message.text
es_vacaciones = context.user_data.get('tipo') == 'VACACIONES'
# --- Selección de año / cuando ---
async def recibir_anio_vacaciones(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 VAC_ANIO
context.user_data["anio"] = anio
await update.message.reply_text("¿Qué *día* termina?", reply_markup=ReplyKeyboardRemove())
return FIN_DIA
if es_vacaciones:
metrics = _calculate_vacation_metrics(texto_fechas)
if metrics["dias_totales"] == 0:
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["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:
context.user_data["anio"] = ANIO_ACTUAL
await update.message.reply_text("¿En qué *día* inicia el permiso? (número, ej: 12)", reply_markup=ReplyKeyboardRemove())
return INICIO_DIA
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["anio"] = anio
await update.message.reply_text("¿Qué *día* termina?", reply_markup=ReplyKeyboardRemove())
return FIN_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
context.user_data.setdefault("anio", ANIO_ACTUAL)
try:
inicio_candidato = date(context.user_data["anio"], mes, context.user_data["inicio_dia"])
if inicio_candidato < date.today():
await update.message.reply_text(
"No entendí las fechas. Usa un formato como `10 al 15 de Octubre`.",
reply_markup=TECLADO_MESES,
"Esa fecha ya pasó este año. ¿Para qué año la agendamos?",
reply_markup=TECLADO_ANIOS
)
return FECHAS
# Si se entiende, guardamos también las métricas preliminares
context.user_data['metricas_preliminares'] = metrics
return VAC_ANIO if context.user_data.get("tipo") == "VACACIONES" else 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":
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
context.user_data['fechas'] = texto_fechas
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
# Generar payload base
payload = {
"record_id": str(uuid.uuid4()),
"solicitante": {
@@ -131,7 +249,10 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE)
"username": user.username
},
"tipo_solicitud": datos['tipo'],
"fechas_texto_original": datos['fechas'],
"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()
}
@@ -141,11 +262,12 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE)
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 = datos.get('metricas_preliminares') or _calculate_vacation_metrics(datos['fechas'])
metrics = _calculate_vacation_metrics_from_dates(fechas)
if metrics["dias_totales"] > 0:
payload["metricas"] = metrics
@@ -154,27 +276,49 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE)
if dias <= 5:
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 = "REVISION_MANUAL"
mensaje = f"🟡 Solicitud de {dias} días recibida. Tu manager la revisará pronto."
else: # 12+
else: # 12-30
status = "PRE_APROBADO"
mensaje = f"🟢 ¡Excelente planeación! Tu solicitud de {dias} días ha sido pre-aprobada."
mensaje = f"🟢 ¡Excelente planeación! Tu solicitud de {dias} días ha sido pre-aprobada (ideal: 12 días)."
payload["status_inicial"] = status
await update.message.reply_text(mensaje)
else:
# Si no se pudieron parsear las fechas
payload["status_inicial"] = "ERROR_FECHAS"
await update.message.reply_text("🤔 No entendí las fechas. Por favor, usa un formato como '10 al 15 de Octubre'.")
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'
if enviados > 0:
await update.message.reply_text(f"✅ Solicitud de *{tipo_solicitud_texto}* enviada a tu Manager.")
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:
await update.message.reply_text("⚠️ No hay webhook configurado o falló el envío. RH lo revisará.")
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}")
else:
await update.message.reply_text(f"⚠️ No hay webhook configurado o falló el envío. RH lo revisará.\n\n{resumen}")
except Exception as e:
print(f"Error enviando webhook: {e}")
await update.message.reply_text("⚠️ Error enviando la solicitud.")
@@ -190,7 +334,11 @@ async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
vacaciones_handler = ConversationHandler(
entry_points=[CommandHandler("vacaciones", start_vacaciones)],
states={
FECHAS: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fechas)],
VAC_ANIO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_anio_vacaciones)],
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)],
MOTIVO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_motivo_fin)]
},
fallbacks=[CommandHandler("cancelar", cancelar)]
@@ -199,7 +347,13 @@ vacaciones_handler = ConversationHandler(
permiso_handler = ConversationHandler(
entry_points=[CommandHandler("permiso", start_permiso)],
states={
FECHAS: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fechas)],
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)]