diff --git a/.env.example b/.env.example index 1b51c2e..bedd649 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,16 @@ # Configuración de Telegram TELEGRAM_TOKEN=TU_TOKEN_NUEVO_AQUI +TELEGRAM_ADMIN_CHAT_ID=TELEGRAM_ADMIN_CHAT_ID +OPENAI_API_KEY=SK...... +GOOGLE_API_KEY=AIzaSyBqH5... + # Webhooks de n8n (puedes agregar más aquí en el futuro) -WEBHOOK_CONTRATO= -WEBHOOK_PRINT= -WEBHOOK_VACACIONES= +WEBHOOK_CONTRATO=url +WEBHOOK_PRINT=url +WEBHOOK_VACACIONES=url +WEBHOOK_PERMISOS=url +WEBHOOK_DATA_EMPLEADO=url # --- DATABASE --- # Usado por el servicio de la base de datos en docker-compose.yml @@ -23,4 +29,5 @@ IMAP_SERVER=imap.hostinger.com IMAP_PORT=993 IMAP_USER=your_email@example.com IMAP_PASSWORD=your_password +PRINTER_EMAIL=your_email@example.com diff --git a/Screenshot_20251214-112252.png b/Screenshot_20251214-112252.png new file mode 100644 index 0000000..f8fd1dd Binary files /dev/null and b/Screenshot_20251214-112252.png differ diff --git a/Vanessa.md b/Vanessa.md new file mode 100644 index 0000000..a15f792 --- /dev/null +++ b/Vanessa.md @@ -0,0 +1,277 @@ +# 👩‍💼 Vanessa Bot Brain + +### Vanity / Soul — HR Automation + +![Python](https://img.shields.io/badge/Python-3.11%2B-blue) +![Telegram](https://img.shields.io/badge/Telegram-Bot-blue) +![AI](https://img.shields.io/badge/AI-OpenAI%20%7C%20Gemini-green) +![n8n](https://img.shields.io/badge/Integration-n8n-red) + +**Vanessa** es la Asistente Virtual de Recursos Humanos para **Vanity / Soul**. No es solo un bot de comandos: es un **cerebro central modular** diseñado para gestionar el ciclo de vida de las socias —contratación, solicitudes internas y servicios— con una personalidad cálida, eficiente y humana. + +--- + +## 🧠 Filosofía del Diseño + +Vanessa fue construida bajo tres principios: + +1. **Conversaciones humanas, datos estrictos** + La UX es natural; la salida siempre es un **payload JSON inmutable**. + +2. **Estado efímero, persistencia externa** + El bot no guarda información sensible. Todo se envía a **n8n + Base de Datos**. + +3. **Modularidad real** + Cada habilidad vive en su propio archivo y puede evolucionar sin romper el sistema. + +--- + +## 🏗️ Arquitectura del Sistema + +Arquitectura **modular y desacoplada**: + +* **Cerebro (`main.py`)** + Orquesta Telegram, gestiona sesiones y enruta comandos. + +* **Habilidades (`/modules`)** + Cada módulo implementa un flujo conversacional completo. + +* **Inteligencia Artificial** + OpenAI o Gemini para clasificación semántica y entendimiento de texto libre. + +* **Persistencia (n8n + DB)** + Recepción de eventos mediante Webhooks con UUID y timestamp. + +--- + +## 📂 Estructura del Proyecto + +```text +/vanity_bot_brain +│ +├── .env # Credenciales y Webhooks +├── main.py # Cerebro / Orquestador +├── requirements.txt # Dependencias +├── README.md # Documentación +│ +└── modules/ # HABILIDADES + ├── __init__.py + ├── onboarding.py # /welcome — Contrato (35 pasos) + ├── rh_requests.py # /vacaciones y /permiso (IA) + └── printer.py # /print — Envío de archivos +``` + +--- + +## 💬 Módulos y Flujos Conversacionales + +### 1️⃣ Onboarding — `/welcome` + +**Objetivo** +Recopilar la información completa para el contrato de nuevas socias. + +**Características clave** + +* Flujo guiado de **35 pasos** +* Normalización de RFC y CURP +* Validación de fechas +* Teclados dinámicos +* Referencias personales en bucle + +**Ejemplo de conversación** + +``` +User: /welcome +Vanessa: ¡Hola Ana! 👋 Soy Vanessa de RH. Vamos a dejar listo tu registro. +Vanessa: ¿Cómo te gusta que te llamemos? +User: Anita +Vanessa: Perfecto ✨ ¿Cuál es tu nombre completo como aparece en tu INE? +... +Vanessa: ¡Listo! ✅ Tu información ya está en el sistema. Bienvenida a Vanity. +``` + +**Payload enviado a n8n** + +```json +{ + "candidato": { + "nombre_oficial": "ANA MARIA PEREZ", + "rfc": "PEQA901010...", + "curp": "PEQA901010...", + "fecha_nacimiento": "1990-10-10" + }, + "laboral": { + "rol_id": "manager", + "sucursal_id": "plaza_cima", + "fecha_inicio": "2026-01-15" + }, + "referencias": [ + { "nombre": "Juan", "telefono": "555...", "relacion": "Trabajo" }, + { "nombre": "Luisa", "telefono": "844...", "relacion": "Familiar" } + ], + "metadata": { + "timestamp": "2025-12-14T10:00:00-06:00" + } +} +``` + +--- + +### 2️⃣ Vacaciones — `/vacaciones` + +**Objetivo** +Gestionar descansos aplicando reglas de negocio automáticamente. + +**Semáforo de decisión** + +* 🔴 Menos de 5 días → Rechazo automático +* 🟡 6 a 11 días → Revisión manual +* 🟢 12+ días → Pre-aprobado + +**Ejemplo** + +``` +Vanessa: Días solicitados: 6 +Vanessa: Anticipación: 35 días +🟢 Excelente planeación. Solicitud pre-aprobada. +``` + +**Payload generado** + +```json +{ + "record_id": "uuid-v4-unico", + "tipo_solicitud": "VACACIONES", + "fechas": { + "inicio": "2026-01-20", + "fin": "2026-01-25" + }, + "metricas": { + "dias_totales": 6, + "dias_anticipacion": 35 + }, + "status_inicial": "PRE_APROBADO", + "created_at": "2025-12-14T10:05:00-06:00" +} +``` + +--- + +### 3️⃣ Permisos con IA — `/permiso` + +**Objetivo** +Registrar incidencias, salidas o permisos cortos. + +**IA aplicada** +El bot analiza el texto libre y clasifica el motivo: + +* EMERGENCIA +* MÉDICO +* TRÁMITE +* PERSONAL + +**Ejemplo** + +``` +Usuario: Mi hijo se cayó en la escuela +Vanessa: Categoría detectada → EMERGENCIA 🚨 +``` + +**Payload** + +```json +{ + "record_id": "uuid-v4-unico", + "tipo_solicitud": "PERMISO", + "motivo_usuario": "Mi hijo se cayó en la escuela...", + "categoria_detectada": "EMERGENCIA", + "fechas": { + "inicio": "2025-12-15", + "fin": "2025-12-15" + }, + "created_at": "2025-12-14T10:10:00-06:00" +} +``` + +--- + +### 4️⃣ Impresión — `/print` + +**Objetivo** +Enviar documentos directamente a la cola de impresión de la oficina. + +**Soporta** + +* PDF +* Word +* Imágenes + +--- + +## 🛠️ Instalación y Ejecución con Docker + +### Requisitos + +* Docker +* Docker Compose + +### 1. Configuración del Entorno + +Antes de iniciar, es necesario configurar las variables de entorno. + +1. **Crear el archivo `.env`**: Copia el archivo de ejemplo `.env.example` y renómbralo a `.env`. + ```bash + cp .env.example .env + ``` +2. **Editar las variables**: Abre el archivo `.env` y rellena todas las variables requeridas: + * `TELEGRAM_TOKEN`: El token de tu bot de Telegram. + * `GOOGLE_API_KEY`: Tu clave de API de Google para la IA de Gemini. + * `WEBHOOK_*`: Las URLs de los webhooks de tu sistema de automatización (ej. n8n). + * `MYSQL_*`: Las credenciales para la base de datos (puedes dejar las que vienen por defecto si solo es para desarrollo local). + * `SMTP_*`: Las credenciales de tu servidor de correo para la función de impresión. + +### 2. Construcción y Ejecución + +Una vez configurado el entorno, el proyecto se gestiona fácilmente con Docker Compose. + +1. **Construir las imágenes**: Este comando crea las imágenes de Docker para el bot y la base de datos. + ```bash + docker-compose build + ``` +2. **Iniciar los servicios**: Este comando inicia el bot y la base de datos en segundo plano. + ```bash + docker-compose up -d + ``` + +El bot ahora estará en funcionamiento. Para detener los servicios, puedes usar `docker-compose down`. + +--- + +## 📊 Esquema de Base de Datos Sugerido + +Tabla: **rh_solicitudes** + +| Campo | Tipo | Descripción | +| ----------------- | --------- | ---------------------- | +| record_id | UUID | Identificador único | +| user_id | BIGINT | Telegram ID | +| nombre | VARCHAR | Nombre del colaborador | +| tipo | VARCHAR | VACACIONES / PERMISO | +| fechas | JSON | Rango de fechas | +| motivo | TEXT | Texto original | +| categoria | VARCHAR | Clasificación IA | +| dias_anticipacion | INT | Métrica RH | +| status_bot | VARCHAR | Resultado automático | +| created_at | TIMESTAMP | Zona MTY | + +--- + +## 🚀 Extensibilidad + +Para agregar un nuevo comando: + +1. Crear un archivo en `/modules` +2. Implementar su flujo +3. Registrar el comando en `main.py` + +Vanessa ya sabe pensar. Solo enséñale una nueva habilidad. 🧠 diff --git a/docker-compose.yml b/docker-compose.yml index 2bd5e87..8b72f9d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,6 @@ services: restart: always env_file: - .env - environment: - - DATABASE_URL=mysql+mysqlconnector://${MYSQL_USER}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE} depends_on: - db volumes: diff --git a/modules/ai.py b/modules/ai.py new file mode 100644 index 0000000..c0cf541 --- /dev/null +++ b/modules/ai.py @@ -0,0 +1,41 @@ +import os +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 diff --git a/modules/database.py b/modules/database.py index f17e272..1ca2f76 100644 --- a/modules/database.py +++ b/modules/database.py @@ -8,12 +8,21 @@ import logging # Configuración de logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') -# Obtener la URL de la base de datos desde las variables de entorno -DATABASE_URL = os.getenv("DATABASE_URL", "mysql+mysqlconnector://user:password@db:3306/vanessa_logs") +# Construir la URL de la base de datos desde las variables de entorno individuales +try: + user = os.getenv("MYSQL_USER") + password = os.getenv("MYSQL_PASSWORD") + host = "db" # El nombre del servicio de la base de datos en docker-compose + database = os.getenv("MYSQL_DATABASE") + DATABASE_URL = f"mysql+mysqlconnector://{user}:{password}@{host}:3306/{database}" + + # Crear el motor de la base de datos + engine = create_engine(DATABASE_URL) + metadata = MetaData() -# Crear el motor de la base de datos -engine = create_engine(DATABASE_URL) -metadata = MetaData() +except AttributeError: + logging.error("Error: Faltan una o más variables de entorno para la base de datos (MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE).") + exit(1) # Base para los modelos declarativos Base = declarative_base() diff --git a/modules/rh_requests.py b/modules/rh_requests.py index 2cdd714..7db1f43 100644 --- a/modules/rh_requests.py +++ b/modules/rh_requests.py @@ -1,22 +1,69 @@ import os +import re import requests -from telegram import Update, ReplyKeyboardMarkup, ReplyKeyboardRemove +import uuid +from datetime import datetime, date +from telegram import Update from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters from modules.database import log_request +from modules.ai import classify_reason + +TIPO_SOLICITITUD, FECHAS, MOTIVO = range(3) + +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". + """ + 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 + } + + # 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} + + 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} -TIPO_SOLICITUD, FECHAS, MOTIVO = range(3) 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['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)") return FECHAS 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['tipo'] = 'Permiso Especial' + context.user_data['tipo'] = 'PERMISO' await update.message.reply_text("⏱️ **Solicitud de Permiso**\n\n¿Para qué día y horario lo necesitas?") return FECHAS @@ -29,25 +76,63 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE) motivo = update.message.text datos = context.user_data user = update.effective_user - - # Payload para n8n + + # Generar payload base payload = { - "solicitante": user.full_name, - "id_telegram": user.id, + "record_id": str(uuid.uuid4()), + "solicitante": { + "id_telegram": user.id, + "nombre": user.full_name + }, "tipo_solicitud": datos['tipo'], - "fechas": datos['fechas'], - "motivo": motivo + "fechas_texto_original": datos['fechas'], + "motivo_usuario": motivo, + "created_at": datetime.now().isoformat() } - webhook = os.getenv("WEBHOOK_VACACIONES") + if datos['tipo'] == 'PERMISO': + webhook = os.getenv("WEBHOOK_PERMISOS") + categoria = classify_reason(motivo) + payload["categoria_detectada"] = categoria + await update.message.reply_text(f"Categoría detectada → **{categoria}** 🚨") + + elif datos['tipo'] == 'VACACIONES': + webhook = os.getenv("WEBHOOK_VACACIONES") + metrics = _calculate_vacation_metrics(datos['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 6 <= dias <= 11: + status = "REVISION_MANUAL" + mensaje = f"🟡 Solicitud de {dias} días recibida. Tu manager la revisará pronto." + else: # 12+ + status = "PRE_APROBADO" + mensaje = f"🟢 ¡Excelente planeación! Tu solicitud de {dias} días ha sido pre-aprobada." + + 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'.") + try: - requests.post(webhook, json=payload) - await update.message.reply_text(f"✅ Solicitud de *{datos['tipo']}* enviada a tu Manager.") - except: + if webhook: + requests.post(webhook, json=payload) + tipo_solicitud_texto = "Permiso" if datos['tipo'] == 'PERMISO' else 'Vacaciones' + await update.message.reply_text(f"✅ Solicitud de *{tipo_solicitud_texto}* enviada a tu Manager.") + 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 diff --git a/requirements.txt b/requirements.txt index b4749ef..7050dc8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ python-telegram-bot python-dotenv requests SQLAlchemy -mysql-connector-python \ No newline at end of file +mysql-connector-python +google-generativeai \ No newline at end of file