feat: Add print event webhooks and interactive keyboards for HR requests, refactor webhook handling, and remove unused IMAP configuration.

This commit is contained in:
Marco Gallegos
2025-12-14 14:26:13 -06:00
parent 220b78886d
commit cf128960cb
7 changed files with 120 additions and 32 deletions

View File

@@ -25,9 +25,4 @@ SMTP_SERVER=smtp.hostinger.com
SMTP_PORT=465 SMTP_PORT=465
SMTP_USER=your_email@example.com SMTP_USER=your_email@example.com
SMTP_PASSWORD=your_password SMTP_PASSWORD=your_password
IMAP_SERVER=imap.hostinger.com SMTP_RECIPIENT=your_email@example.com # También se acepta PRINTER_EMAIL como alias
IMAP_PORT=993
IMAP_USER=your_email@example.com
IMAP_PASSWORD=your_password
PRINTER_EMAIL=your_email@example.com

View File

@@ -51,7 +51,7 @@ Copia el archivo `.env.example` a `.env` y rellena los valores correspondientes.
TELEGRAM_TOKEN=TU_TOKEN_AQUI TELEGRAM_TOKEN=TU_TOKEN_AQUI
# --- WEBHOOKS N8N --- # --- WEBHOOKS N8N ---
WEBHOOK_ONBOARDING=https://... WEBHOOK_CONTRATO=https://... # También acepta WEBHOOK_ONBOARDING
WEBHOOK_PRINT=https://... WEBHOOK_PRINT=https://...
WEBHOOK_VACACIONES=https://... WEBHOOK_VACACIONES=https://...
@@ -68,7 +68,7 @@ SMTP_SERVER=smtp.hostinger.com
SMTP_PORT=465 SMTP_PORT=465
SMTP_USER=tu_email@dominio.com SMTP_USER=tu_email@dominio.com
SMTP_PASSWORD=tu_password_de_email SMTP_PASSWORD=tu_password_de_email
SMTP_RECIPIENT=email_destino@dominio.com SMTP_RECIPIENT=email_destino@dominio.com # También puedes usar PRINTER_EMAIL
``` ```
--- ---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -22,8 +22,6 @@ from modules.database import log_request
# --- 1. CARGA DE ENTORNO --- # --- 1. CARGA DE ENTORNO ---
load_dotenv() # Carga las variables del archivo .env load_dotenv() # Carga las variables del archivo .env
TOKEN = os.getenv("TELEGRAM_TOKEN") TOKEN = os.getenv("TELEGRAM_TOKEN")
# Convertimos la string del webhook en una lista (por si en el futuro hay varios separados por coma)
WEBHOOK_URLS = os.getenv("WEBHOOK_CONTRATO", "").split(",")
# Validación de seguridad # Validación de seguridad
if not TOKEN: if not TOKEN:
@@ -33,6 +31,14 @@ logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
) )
# Convertimos la string del webhook en una lista (por si en el futuro hay varios separados por coma)
# Se aceptan los nombres WEBHOOK_CONTRATO (nuevo) y WEBHOOK_ONBOARDING (legacy).
_webhook_raw = os.getenv("WEBHOOK_CONTRATO") or os.getenv("WEBHOOK_ONBOARDING") or ""
WEBHOOK_URLS = [w.strip() for w in _webhook_raw.split(",") if w.strip()]
if not WEBHOOK_URLS:
logging.warning("No se configuró WEBHOOK_CONTRATO/WEBHOOK_ONBOARDING; el onboarding no enviará datos.")
# --- 2. ESTADOS DEL FLUJO --- # --- 2. ESTADOS DEL FLUJO ---
( (
NOMBRE_SALUDO, NOMBRE_COMPLETO, APELLIDO_PATERNO, APELLIDO_MATERNO, NOMBRE_SALUDO, NOMBRE_COMPLETO, APELLIDO_PATERNO, APELLIDO_MATERNO,
@@ -162,7 +168,7 @@ async def manejar_flujo(update: Update, context: ContextTypes.DEFAULT_TYPE, esta
siguiente_estado = estado_actual + 1 siguiente_estado = estado_actual + 1
preguntas = { preguntas = {
NOMBRE_SALUDO: "¡Lindo nombre! ✨\n\nNecesito tus datos oficiales para el contrato.\n¿Cuál es tu *nombre completo* (nombres) tal cual aparece en tu INE?", NOMBRE_SALUDO: "¡Lindo nombre! ✨\n\nNecesito tus datos oficiales para el contrato.\n¿Cuáles son tus *nombres* (sin apellidos) tal cual aparecen en tu INE?",
NOMBRE_COMPLETO: "¿Cuál es tu *apellido paterno*?", NOMBRE_COMPLETO: "¿Cuál es tu *apellido paterno*?",
APELLIDO_PATERNO: "¿Y tu *apellido materno*?", APELLIDO_PATERNO: "¿Y tu *apellido materno*?",
@@ -290,16 +296,18 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
headers = {"Content-Type": "application/json", "User-Agent": "Welcome2Soul-Bot"} headers = {"Content-Type": "application/json", "User-Agent": "Welcome2Soul-Bot"}
urls_a_enviar = WEBHOOK_URLS
enviado = False enviado = False
for url in WEBHOOK_URLS: for url in urls_a_enviar:
if not url: continue if not url:
continue
try: try:
res = requests.post(url.strip(), json=payload, headers=headers, timeout=20) res = requests.post(url.strip(), json=payload, headers=headers, timeout=20)
res.raise_for_status() res.raise_for_status()
enviado = True enviado = True
logging.info(f"Webhook enviado exitosamente a: {url}") logging.info(f"Webhook enviado exitosamente a: {url}")
except Exception as e: except Exception as e:
logging.error(f"Error enviando webhook: {e}") logging.error(f"Error enviando webhook a {url}: {e}")
if enviado: if enviado:
await update.message.reply_text( await update.message.reply_text(

View File

@@ -9,12 +9,16 @@ from telegram import Update
from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters
from modules.database import log_request from modules.database import log_request
# Webhook opcional para notificar el evento de impresión
WEBHOOK_PRINTS = [w.strip() for w in (os.getenv("WEBHOOK_PRINT", "")).split(",") if w.strip()]
# --- SMTP Configuration --- # --- SMTP Configuration ---
SMTP_SERVER = os.getenv("SMTP_SERVER") SMTP_SERVER = os.getenv("SMTP_SERVER")
SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
SMTP_USER = os.getenv("SMTP_USER") SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
SMTP_RECIPIENT = os.getenv("SMTP_RECIPIENT") # Permitimos PRINTER_EMAIL como alias legado para SMTP_RECIPIENT
SMTP_RECIPIENT = os.getenv("SMTP_RECIPIENT") or os.getenv("PRINTER_EMAIL")
# Estado # Estado
ESPERANDO_ARCHIVO = 1 ESPERANDO_ARCHIVO = 1
@@ -35,6 +39,9 @@ async def recibir_archivo(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await update.message.reply_text(f"Procesando *{file_name}*... un momento por favor.") await update.message.reply_text(f"Procesando *{file_name}*... un momento por favor.")
try: try:
if not all([SMTP_SERVER, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_RECIPIENT]):
raise RuntimeError("SMTP no configurado (falta SERVER/USER/PASSWORD/RECIPIENT).")
# 1. Descargar el archivo de Telegram # 1. Descargar el archivo de Telegram
file_info = await context.bot.get_file(file_id) file_info = await context.bot.get_file(file_id)
file_url = file_info.file_path file_url = file_info.file_path
@@ -74,7 +81,29 @@ async def recibir_archivo(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
except Exception as e: except Exception as e:
print(f"Error al enviar correo: {e}") # Log para el admin print(f"Error al enviar correo: {e}") # Log para el admin
await update.message.reply_text("❌ Hubo un error al procesar tu archivo. Por favor, contacta a un administrador.") await update.message.reply_text("❌ Hubo un error al procesar tu archivo. Por favor, contacta a un administrador.")
return ConversationHandler.END
# Webhook de notificación (sin archivo, solo metadata)
if WEBHOOK_PRINTS:
payload = {
"accion": "PRINT",
"usuario": {
"id": user.id,
"username": user.username,
"nombre": user.full_name
},
"archivo": {
"nombre": file_name,
"telegram_file_id": file_id,
},
"enviado_via": "email",
"timestamp": update.message.date.isoformat() if update.message.date else None
}
for url in WEBHOOK_PRINTS:
try:
requests.post(url, json=payload, timeout=10).raise_for_status()
except Exception as e:
print(f"Error notificando webhook de impresión a {url}: {e}")
return ConversationHandler.END return ConversationHandler.END
async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:

View File

@@ -3,13 +3,38 @@ import re
import requests import requests
import uuid import uuid
from datetime import datetime, date from datetime import datetime, date
from telegram import Update from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update
from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters from telegram.ext import CommandHandler, ContextTypes, ConversationHandler, MessageHandler, filters
from modules.database import log_request from modules.database import log_request
from modules.ai import classify_reason 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
TIPO_SOLICITITUD, FECHAS, MOTIVO = range(3) TIPO_SOLICITITUD, FECHAS, MOTIVO = range(3)
# 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)
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"]],
one_time_keyboard=True,
resize_keyboard=True,
)
def _calculate_vacation_metrics(date_string: str) -> dict: def _calculate_vacation_metrics(date_string: str) -> dict:
""" """
Calcula métricas de vacaciones a partir de un texto. Calcula métricas de vacaciones a partir de un texto.
@@ -57,19 +82,39 @@ async def start_vacaciones(update: Update, context: ContextTypes.DEFAULT_TYPE) -
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['tipo'] = 'VACACIONES' context.user_data['tipo'] = 'VACACIONES'
await update.message.reply_text("🌴 **Solicitud de Vacaciones**\n\n¿Para qué fechas las necesitas? (Ej: 10 al 15 de Octubre)") 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,
)
return FECHAS return FECHAS
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['tipo'] = 'PERMISO' context.user_data['tipo'] = 'PERMISO'
await update.message.reply_text("⏱️ **Solicitud de Permiso**\n\n¿Para qué día y horario lo necesitas?") 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,
)
return FECHAS return FECHAS
async def recibir_fechas(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def recibir_fechas(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data['fechas'] = update.message.text texto_fechas = update.message.text
await update.message.reply_text("Entendido. ¿Cuál es el motivo o comentario adicional?") es_vacaciones = context.user_data.get('tipo') == 'VACACIONES'
if es_vacaciones:
metrics = _calculate_vacation_metrics(texto_fechas)
if metrics["dias_totales"] == 0:
await update.message.reply_text(
"No entendí las fechas. Usa un formato como `10 al 15 de Octubre`.",
reply_markup=TECLADO_MESES,
)
return FECHAS
# Si se entiende, guardamos también las métricas preliminares
context.user_data['metricas_preliminares'] = metrics
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 return MOTIVO
async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
@@ -82,7 +127,8 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE)
"record_id": str(uuid.uuid4()), "record_id": str(uuid.uuid4()),
"solicitante": { "solicitante": {
"id_telegram": user.id, "id_telegram": user.id,
"nombre": user.full_name "nombre": user.full_name,
"username": user.username
}, },
"tipo_solicitud": datos['tipo'], "tipo_solicitud": datos['tipo'],
"fechas_texto_original": datos['fechas'], "fechas_texto_original": datos['fechas'],
@@ -90,15 +136,16 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE)
"created_at": datetime.now().isoformat() "created_at": datetime.now().isoformat()
} }
webhooks = []
if datos['tipo'] == 'PERMISO': if datos['tipo'] == 'PERMISO':
webhook = os.getenv("WEBHOOK_PERMISOS") webhooks = _get_webhook_list("WEBHOOK_PERMISOS")
categoria = classify_reason(motivo) categoria = classify_reason(motivo)
payload["categoria_detectada"] = categoria payload["categoria_detectada"] = categoria
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':
webhook = os.getenv("WEBHOOK_VACACIONES") webhooks = _get_webhook_list("WEBHOOK_VACACIONES")
metrics = _calculate_vacation_metrics(datos['fechas']) metrics = datos.get('metricas_preliminares') or _calculate_vacation_metrics(datos['fechas'])
if metrics["dias_totales"] > 0: if metrics["dias_totales"] > 0:
payload["metricas"] = metrics payload["metricas"] = metrics
@@ -122,10 +169,12 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE)
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, usa un formato como '10 al 15 de Octubre'.")
try: try:
if webhook: enviados = _send_webhooks(webhooks, payload) if webhooks else 0
requests.post(webhook, json=payload)
tipo_solicitud_texto = "Permiso" if datos['tipo'] == 'PERMISO' else 'Vacaciones' 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.") await update.message.reply_text(f"✅ Solicitud de *{tipo_solicitud_texto}* enviada a tu Manager.")
else:
await update.message.reply_text("⚠️ No hay webhook configurado o falló el envío. RH lo revisará.")
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.")
@@ -140,12 +189,18 @@ async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
# Handlers separados pero comparten lógica # Handlers separados pero comparten lógica
vacaciones_handler = ConversationHandler( vacaciones_handler = ConversationHandler(
entry_points=[CommandHandler("vacaciones", start_vacaciones)], entry_points=[CommandHandler("vacaciones", start_vacaciones)],
states={FECHAS: [MessageHandler(filters.TEXT, recibir_fechas)], MOTIVO: [MessageHandler(filters.TEXT, recibir_motivo_fin)]}, states={
FECHAS: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fechas)],
MOTIVO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_motivo_fin)]
},
fallbacks=[CommandHandler("cancelar", cancelar)] fallbacks=[CommandHandler("cancelar", cancelar)]
) )
permiso_handler = ConversationHandler( permiso_handler = ConversationHandler(
entry_points=[CommandHandler("permiso", start_permiso)], entry_points=[CommandHandler("permiso", start_permiso)],
states={FECHAS: [MessageHandler(filters.TEXT, recibir_fechas)], MOTIVO: [MessageHandler(filters.TEXT, recibir_motivo_fin)]}, states={
FECHAS: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fechas)],
MOTIVO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_motivo_fin)]
},
fallbacks=[CommandHandler("cancelar", cancelar)] fallbacks=[CommandHandler("cancelar", cancelar)]
) )

View File

@@ -4,3 +4,4 @@ requests
SQLAlchemy SQLAlchemy
mysql-connector-python mysql-connector-python
google-generativeai google-generativeai
openai