Files
telegram_new_socias/modules/rh_requests.py

361 lines
15 KiB
Python

import os
import requests
import uuid
from datetime import datetime, date
from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update
from telegram.ext import CommandHandler, ContextTypes, ConversationHandler, MessageHandler, filters
from modules.database import log_request
from modules.ai import classify_reason
# 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
(
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"
]
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."""
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()},
}
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'
context.user_data["anio"] = ANIO_ACTUAL
await update.message.reply_text(
"🌴 **Solicitud de Vacaciones**\n\nUsaré el año actual. ¿En qué *día* inicia tu descanso? (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_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
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(
"Esa fecha ya pasó este año. ¿Para qué año la agendamos?",
reply_markup=TECLADO_ANIOS
)
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
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": str(uuid.uuid4()),
"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"]
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-30
status = "PRE_APROBADO"
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:
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}")
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.")
return ConversationHandler.END
async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.message.reply_text("Solicitud cancelada.")
return ConversationHandler.END
# Handlers separados pero comparten lógica
vacaciones_handler = ConversationHandler(
entry_points=[CommandHandler("vacaciones", start_vacaciones)],
states={
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)]
)
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)]
)