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 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. 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. Vanessa no es un chatbot genérico: es una interfaz conversacional para procesos reales de negocio.
- Onboarding completo de nuevas socias (`/welcome`) - Onboarding completo de nuevas socias (`/welcome`)
- Envío de archivos a impresión por correo electrónico (`/print`)
- Solicitud de vacaciones (`/vacaciones`) - Solicitud de vacaciones (`/vacaciones`)
- Solicitud de permisos por horas (`/permiso`) - 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 ├── __init__.py
├── database.py # Módulo de conexión a la base de datos ├── database.py # Módulo de conexión a la base de datos
├── onboarding.py # Flujo /welcome (onboarding RH) ├── onboarding.py # Flujo /welcome (onboarding RH)
├── printer.py # Flujo /print (impresión por email)
└── rh_requests.py # /vacaciones y /permiso └── rh_requests.py # /vacaciones y /permiso
``` ```
@@ -52,8 +50,8 @@ TELEGRAM_TOKEN=TU_TOKEN_AQUI
# --- WEBHOOKS N8N --- # --- WEBHOOKS N8N ---
WEBHOOK_ONBOARDING=https://... # Alias aceptado: WEBHOOK_CONTRATO WEBHOOK_ONBOARDING=https://... # Alias aceptado: WEBHOOK_CONTRATO
WEBHOOK_PRINT=https://...
WEBHOOK_VACACIONES=https://... WEBHOOK_VACACIONES=https://...
WEBHOOK_PERMISOS=https://...
# --- DATABASE --- # --- DATABASE ---
# Usado por el servicio de la base de datos en docker-compose.yml # Usado por el servicio de la base de datos en docker-compose.yml
@@ -62,13 +60,6 @@ MYSQL_USER=user
MYSQL_PASSWORD=password MYSQL_PASSWORD=password
MYSQL_ROOT_PASSWORD=rootpassword 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 ### modules/onboarding.py
Flujo conversacional complejo que recolecta datos de nuevas empleadas y los envía a un webhook de n8n. Flujo conversacional complejo que recolecta datos de nuevas empleadas y los envía a un webhook de n8n.
Incluye derivadas útiles: `num_ext_texto` (número en letras, con interior) y `numero_empleado` (primeras 4 del CURP + fecha de ingreso).
### 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.
### modules/rh_requests.py ### modules/rh_requests.py
- Maneja solicitudes simples de RH (Vacaciones y Permisos) y las envía a un webhook de n8n. - 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. - **Python como cerebro**: Lógica de negocio y orquestación.
- **Docker para despliegue**: Entornos consistentes y portátiles. - **Docker para despliegue**: Entornos consistentes y portátiles.
- **MySQL para persistencia**: Registro auditable de todas las interacciones. - **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. - **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 # Cargar variables de entorno antes de importar módulos que las usan
load_dotenv() load_dotenv()
from telegram import Update, ReplyKeyboardMarkup from telegram import Update, ReplyKeyboardMarkup, BotCommand
from telegram.constants import ParseMode from telegram.constants import ParseMode
from telegram.ext import Application, Defaults, CommandHandler, ContextTypes from telegram.ext import Application, Defaults, CommandHandler, ContextTypes
# --- IMPORTAR HABILIDADES --- # --- IMPORTAR HABILIDADES ---
from modules.onboarding import onboarding_handler from modules.onboarding import onboarding_handler
from modules.printer import print_handler
from modules.rh_requests import vacaciones_handler, permiso_handler from modules.rh_requests import vacaciones_handler, permiso_handler
from modules.database import log_request from modules.database import log_request
# from modules.finder import finder_handler (Si lo creas después) # 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) log_request(user.id, user.username, "start", update.message.text)
texto = ( texto = (
"👩‍💼 **Hola, soy Vanessa. ¿En qué puedo ayudarte hoy?**\n\n" "👩‍💼 **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( teclado = ReplyKeyboardMarkup(
[["/welcome", "/print"], ["/vacaciones", "/permiso"]], [["/welcome"], ["/vacaciones", "/permiso"]],
resize_keyboard=True resize_keyboard=True
) )
await update.message.reply_text(texto, reply_markup=teclado) 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(): def main():
# Configuración Global # Configuración Global
defaults = Defaults(parse_mode=ParseMode.MARKDOWN) 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 --- # --- REGISTRO DE HABILIDADES ---
@@ -48,7 +67,6 @@ def main():
# 2. Habilidades Complejas (Conversaciones) # 2. Habilidades Complejas (Conversaciones)
app.add_handler(onboarding_handler) app.add_handler(onboarding_handler)
app.add_handler(print_handler)
app.add_handler(vacaciones_handler) app.add_handler(vacaciones_handler)
app.add_handler(permiso_handler) app.add_handler(permiso_handler)
# app.add_handler(finder_handler) # app.add_handler(finder_handler)

View File

@@ -67,6 +67,55 @@ def limpiar_texto_general(texto: str) -> str:
t = " ".join(texto.split()) t = " ".join(texto.split())
return "N/A" if t == "0" else t 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 --- # --- 4. TECLADOS DINÁMICOS ---
# Meses: Texto vs Valor # Meses: Texto vs Valor
@@ -130,7 +179,8 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"telegram_id": user.id, "telegram_id": user.id,
"username": user.username or "N/A", "username": user.username or "N/A",
"first_name": user.first_name, "first_name": user.first_name,
"start_ts": datetime.now().timestamp() "start_ts": datetime.now().timestamp(),
"msg_count": 0,
} }
context.user_data["respuestas"] = {} 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: async def manejar_flujo(update: Update, context: ContextTypes.DEFAULT_TYPE, estado_actual: int) -> int:
texto_recibido = update.message.text texto_recibido = update.message.text
respuesta_procesada = limpiar_texto_general(texto_recibido) 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 --- # --- 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: async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
# Guardar última respuesta (Relación Emergencia) # Guardar última respuesta (Relación Emergencia)
context.user_data["respuestas"][EMERGENCIA_RELACION] = limpiar_texto_general(update.message.text) 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.") 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: except Exception:
fecha_nac = "ERROR_FECHA" fecha_nac = "ERROR_FECHA"
fecha_ini = "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 ESTRUCTURADO PARA N8N
payload = { payload = {
@@ -267,6 +327,7 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"calle": r.get(CALLE), "calle": r.get(CALLE),
"num_ext": r.get(NUM_EXTERIOR), "num_ext": r.get(NUM_EXTERIOR),
"num_int": r.get(NUM_INTERIOR), "num_int": r.get(NUM_INTERIOR),
"num_ext_texto": num_ext_texto,
"colonia": r.get(COLONIA), "colonia": r.get(COLONIA),
"cp": r.get(CODIGO_POSTAL), "cp": r.get(CODIGO_POSTAL),
"ciudad": r.get(CIUDAD_RESIDENCIA), "ciudad": r.get(CIUDAD_RESIDENCIA),
@@ -275,7 +336,8 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"laboral": { "laboral": {
"rol_id": r.get(ROL).lower(), # partner, manager... "rol_id": r.get(ROL).lower(), # partner, manager...
"sucursal_id": r.get(SUCURSAL), # plaza_cima, plaza_o "sucursal_id": r.get(SUCURSAL), # plaza_cima, plaza_o
"fecha_inicio": fecha_ini "fecha_inicio": fecha_ini,
"numero_empleado": n_empleado
}, },
"referencias": [ "referencias": [
{"nombre": r.get(REF1_NOMBRE), "telefono": r.get(REF1_TELEFONO), "relacion": r.get(REF1_TIPO)}, {"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"], "telegram_user": meta["username"],
"chat_id": meta["telegram_id"], "chat_id": meta["telegram_id"],
"bot_version": "welcome2soul_v2", "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 os
import re
import requests import requests
import uuid import uuid
from datetime import datetime, date from datetime import datetime, date
@@ -24,105 +23,224 @@ def _send_webhooks(urls: list, payload: dict):
print(f"[webhook] Error enviando a {url}: {e}") print(f"[webhook] Error enviando a {url}: {e}")
return enviados 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 # 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_MESES = ReplyKeyboardMarkup([MESES[i:i+3] for i in range(0, 12, 3)], one_time_keyboard=True, resize_keyboard=True)
TECLADO_PERMISO_RAPIDO = ReplyKeyboardMarkup( MESES_MAP = {nombre.lower(): idx + 1 for idx, nombre in enumerate(MESES)}
[["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"]], 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, one_time_keyboard=True,
resize_keyboard=True, resize_keyboard=True,
) )
def _calculate_vacation_metrics(date_string: str) -> dict: def _parse_dia(texto: str) -> int:
""" try:
Calcula métricas de vacaciones a partir de un texto. dia = int(texto)
Asume un formato como "10 al 15 de Octubre". if 1 <= dia <= 31:
""" return dia
today = date.today() except Exception:
current_year = today.year pass
return 0
# Mapeo de meses en español a número def _parse_mes(texto: str) -> int:
meses = { return MESES_MAP.get(texto.strip().lower(), 0)
'enero': 1, 'febrero': 2, 'marzo': 3, 'abril': 4, 'mayo': 5, 'junio': 6,
'julio': 7, 'agosto': 8, 'septiembre': 9, 'octubre': 10, 'noviembre': 11, 'diciembre': 12 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()
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" def _fmt_fecha(fecha_iso: str) -> str:
match = re.search(r'(\d{1,2})\s*al\s*(\d{1,2})\s*de\s*(\w+)', date_string, re.IGNORECASE) if not fecha_iso:
return "N/A"
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}
try: try:
start_date = date(current_year, month, start_day) return fecha_iso.split("T")[0]
# Si la fecha ya pasó este año, asumir que es del próximo año except Exception:
if start_date < today: return fecha_iso
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}
# --- Vacaciones ---
async def start_vacaciones(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def start_vacaciones(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
user = update.effective_user user = update.effective_user
log_request(user.id, user.username, "vacaciones", update.message.text) log_request(user.id, user.username, "vacaciones", update.message.text)
context.user_data.clear()
context.user_data['tipo'] = 'VACACIONES' context.user_data['tipo'] = 'VACACIONES'
context.user_data["anio"] = ANIO_ACTUAL
await update.message.reply_text( await update.message.reply_text(
"🌴 **Solicitud de Vacaciones**\n\n¿Para qué fechas las necesitas?\nUsa el formato: `10 al 15 de Octubre`.", "🌴 **Solicitud de Vacaciones**\n\nUsaré el año actual. ¿En qué *día* inicia tu descanso? (número, ej: 10)",
reply_markup=TECLADO_MESES, reply_markup=ReplyKeyboardRemove(),
) )
return FECHAS return INICIO_DIA
# --- Permiso ---
async def start_permiso(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def start_permiso(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
user = update.effective_user user = update.effective_user
log_request(user.id, user.username, "permiso", update.message.text) log_request(user.id, user.username, "permiso", update.message.text)
context.user_data.clear()
context.user_data['tipo'] = 'PERMISO' context.user_data['tipo'] = 'PERMISO'
await update.message.reply_text( await update.message.reply_text(
"⏱️ **Solicitud de Permiso**\n\nSelecciona o escribe el día y horario. Ej: `Jueves 15 09:00-11:00`.", "⏱️ **Solicitud de Permiso**\n\n¿Para cuándo lo necesitas?",
reply_markup=TECLADO_PERMISO_RAPIDO, reply_markup=TECLADO_PERMISO_CUANDO,
) )
return FECHAS return PERMISO_CUANDO
async def recibir_fechas(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: # --- Selección de año / cuando ---
texto_fechas = update.message.text async def recibir_anio_vacaciones(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
es_vacaciones = context.user_data.get('tipo') == 'VACACIONES' 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: async def recibir_cuando_permiso(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
metrics = _calculate_vacation_metrics(texto_fechas) texto = update.message.text.strip().lower()
if metrics["dias_totales"] == 0: 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( await update.message.reply_text(
"No entendí las fechas. Usa un formato como `10 al 15 de Octubre`.", "Esa fecha ya pasó este año. ¿Para qué año la agendamos?",
reply_markup=TECLADO_MESES, reply_markup=TECLADO_ANIOS
) )
return FECHAS return VAC_ANIO if context.user_data.get("tipo") == "VACACIONES" else PERMISO_ANIO
# Si se entiende, guardamos también las métricas preliminares except Exception:
context.user_data['metricas_preliminares'] = metrics 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()) await update.message.reply_text("Entendido. ¿Cuál es el motivo o comentario adicional?", reply_markup=ReplyKeyboardRemove())
return MOTIVO 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: async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
motivo = update.message.text motivo = update.message.text
datos = context.user_data datos = context.user_data
user = update.effective_user user = update.effective_user
# Generar payload base 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 = { payload = {
"record_id": str(uuid.uuid4()), "record_id": str(uuid.uuid4()),
"solicitante": { "solicitante": {
@@ -131,7 +249,10 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE)
"username": user.username "username": user.username
}, },
"tipo_solicitud": datos['tipo'], "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, "motivo_usuario": motivo,
"created_at": datetime.now().isoformat() "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") webhooks = _get_webhook_list("WEBHOOK_PERMISOS")
categoria = classify_reason(motivo) categoria = classify_reason(motivo)
payload["categoria_detectada"] = categoria payload["categoria_detectada"] = categoria
payload["horario"] = datos.get("horario", "N/A")
await update.message.reply_text(f"Categoría detectada → **{categoria}** 🚨") await update.message.reply_text(f"Categoría detectada → **{categoria}** 🚨")
elif datos['tipo'] == 'VACACIONES': elif datos['tipo'] == 'VACACIONES':
webhooks = _get_webhook_list("WEBHOOK_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: if metrics["dias_totales"] > 0:
payload["metricas"] = metrics payload["metricas"] = metrics
@@ -154,27 +276,49 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE)
if dias <= 5: if dias <= 5:
status = "RECHAZADO" status = "RECHAZADO"
mensaje = f"🔴 {dias} días es un periodo muy corto. Las vacaciones deben ser de al menos 6 días." 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: elif 6 <= dias <= 11:
status = "REVISION_MANUAL" status = "REVISION_MANUAL"
mensaje = f"🟡 Solicitud de {dias} días recibida. Tu manager la revisará pronto." mensaje = f"🟡 Solicitud de {dias} días recibida. Tu manager la revisará pronto."
else: # 12+ else: # 12-30
status = "PRE_APROBADO" 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 payload["status_inicial"] = status
await update.message.reply_text(mensaje) await update.message.reply_text(mensaje)
else: else:
# Si no se pudieron parsear las fechas
payload["status_inicial"] = "ERROR_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: try:
enviados = _send_webhooks(webhooks, payload) if webhooks else 0 enviados = _send_webhooks(webhooks, payload) if webhooks else 0
tipo_solicitud_texto = "Permiso" if datos['tipo'] == 'PERMISO' else 'Vacaciones' tipo_solicitud_texto = "Permiso" if datos['tipo'] == 'PERMISO' else 'Vacaciones'
if enviados > 0: inicio_txt = _fmt_fecha(payload["fechas"]["inicio"])
await update.message.reply_text(f"✅ Solicitud de *{tipo_solicitud_texto}* enviada a tu Manager.") 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: 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: except Exception as e:
print(f"Error enviando webhook: {e}") print(f"Error enviando webhook: {e}")
await update.message.reply_text("⚠️ Error enviando la solicitud.") 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( vacaciones_handler = ConversationHandler(
entry_points=[CommandHandler("vacaciones", start_vacaciones)], entry_points=[CommandHandler("vacaciones", start_vacaciones)],
states={ 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)] MOTIVO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_motivo_fin)]
}, },
fallbacks=[CommandHandler("cancelar", cancelar)] fallbacks=[CommandHandler("cancelar", cancelar)]
@@ -199,7 +347,13 @@ vacaciones_handler = ConversationHandler(
permiso_handler = ConversationHandler( permiso_handler = ConversationHandler(
entry_points=[CommandHandler("permiso", start_permiso)], entry_points=[CommandHandler("permiso", start_permiso)],
states={ 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)] MOTIVO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_motivo_fin)]
}, },
fallbacks=[CommandHandler("cancelar", cancelar)] fallbacks=[CommandHandler("cancelar", cancelar)]