feat: Implement a new links menu, consolidate the main action keyboard into a ui module, and update employee ID generation logic.

This commit is contained in:
Marco Gallegos
2025-12-15 09:49:50 -06:00
parent 24874da1c7
commit 8422a874d9
7 changed files with 120 additions and 24 deletions

View File

@@ -90,13 +90,13 @@ Si Collify solo consume imágenes ya publicadas, usa el archivo `docker-compose.
1) Construir y publicar la imagen (ejemplo con Buildx y tag con timestamp): 1) Construir y publicar la imagen (ejemplo con Buildx y tag con timestamp):
```bash ```bash
export DOCKER_IMAGE=registry.example.com/vanessa-bot:prod-$(date +%Y%m%d%H%M) export DOCKER_IMAGE=marcogll/vanessa-bot:prod-$(date +%Y%m%d%H%M)
docker buildx build --platform linux/amd64 -t $DOCKER_IMAGE . --push docker buildx build --platform linux/amd64 -t $DOCKER_IMAGE . --push
``` ```
2) Desplegar en el servidor (Collify) usando la imagen publicada: 2) Desplegar en el servidor (Collify) usando la imagen publicada:
```bash ```bash
export DOCKER_IMAGE=registry.example.com/vanessa-bot:prod-20240101 export DOCKER_IMAGE=marcogll/vanessa-bot:prod-20240101
docker compose -f docker-compose.collify.yml pull docker compose -f docker-compose.collify.yml pull
docker compose -f docker-compose.collify.yml up -d docker compose -f docker-compose.collify.yml up -d
``` ```

View File

@@ -2,7 +2,7 @@ version: "3.8"
services: services:
bot: bot:
image: ${DOCKER_IMAGE:-vanessa-bot:latest} image: marcogll/vanessa-bot:latest
container_name: vanessa_bot container_name: vanessa_bot
restart: always restart: always
env_file: env_file:

67
main.py
View File

@@ -1,11 +1,17 @@
import os import os
import logging import logging
from dotenv import load_dotenv from dotenv import load_dotenv
from typing import Optional
# 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, BotCommand from telegram import (
Update,
InlineKeyboardButton,
InlineKeyboardMarkup,
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
@@ -13,30 +19,67 @@ from telegram.ext import Application, Defaults, CommandHandler, ContextTypes
from modules.onboarding import onboarding_handler from modules.onboarding import onboarding_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.ui import main_actions_keyboard
# from modules.finder import finder_handler (Si lo creas después) # from modules.finder import finder_handler (Si lo creas después)
LINK_CURSOS = "https://cursos.vanityexperience.mx/dashboard-2/"
LINK_SITIO = "https://vanityexperience.mx/"
LINK_AGENDA_IOS = "https://apps.apple.com/us/app/fresha-for-business/id1455346253"
LINK_AGENDA_ANDROID = "https://play.google.com/store/apps/details?id=com.fresha.Business"
TOKEN = os.getenv("TELEGRAM_TOKEN") TOKEN = os.getenv("TELEGRAM_TOKEN")
logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO) logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO)
def _guess_platform(update: Update) -> Optional[str]:
"""
Telegram no expone el OS del usuario en mensajes regulares.
Devolvemos None para mostrar ambos links; si en el futuro llegan datos, se pueden mapear aquí.
"""
try:
_ = update.to_dict() # placeholder por si queremos inspeccionar el payload
except Exception:
pass
return None
async def links_menu(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Muestra accesos rápidos a cursos, sitio y descargas."""
user = update.effective_user
log_request(user.id, user.username, "links", update.message.text)
plataforma = _guess_platform(update)
descarga_buttons = []
if plataforma == "ios":
descarga_buttons.append(InlineKeyboardButton("Agenda | iOS", url=LINK_AGENDA_IOS))
elif plataforma == "android":
descarga_buttons.append(InlineKeyboardButton("Agenda | Android", url=LINK_AGENDA_ANDROID))
else:
descarga_buttons = [
InlineKeyboardButton("Agenda | iOS", url=LINK_AGENDA_IOS),
InlineKeyboardButton("Agenda | Android", url=LINK_AGENDA_ANDROID),
]
texto = (
"🌐 Links útiles\n"
"Claro, aquí tienes enlaces que puedes necesitar durante tu estancia con nosotros:\n"
"Toca el que te aplique."
)
botones = [
[InlineKeyboardButton("Cursos Vanity", url=LINK_CURSOS)],
[InlineKeyboardButton("Sitio Vanity", url=LINK_SITIO)],
descarga_buttons,
]
await update.message.reply_text(texto, reply_markup=InlineKeyboardMarkup(botones))
async def menu_principal(update: Update, context: ContextTypes.DEFAULT_TYPE): async def menu_principal(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Muestra el menú de opciones de Vanessa""" """Muestra el menú de opciones de Vanessa"""
user = update.effective_user user = update.effective_user
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"
"Comandos rápidos:\n" "Toca un botón para continuar 👇"
"/welcome — Registro de nuevas empleadas\n"
"/vacaciones — Solicitud de vacaciones\n"
"/permiso — Solicitud de permiso por horas\n\n"
"También tienes los botones rápidos abajo 👇"
) )
teclado = ReplyKeyboardMarkup( await update.message.reply_text(texto, reply_markup=main_actions_keyboard())
[["/welcome"], ["/vacaciones", "/permiso"]],
resize_keyboard=True
)
await update.message.reply_text(texto, reply_markup=teclado)
async def post_init(application: Application): async def post_init(application: Application):
# Mantén los comandos rápidos disponibles en el menú de Telegram # Mantén los comandos rápidos disponibles en el menú de Telegram
@@ -45,6 +88,7 @@ async def post_init(application: Application):
BotCommand("welcome", "Registro de nuevas empleadas"), BotCommand("welcome", "Registro de nuevas empleadas"),
BotCommand("vacaciones", "Solicitar vacaciones"), BotCommand("vacaciones", "Solicitar vacaciones"),
BotCommand("permiso", "Solicitar permiso por horas"), BotCommand("permiso", "Solicitar permiso por horas"),
BotCommand("links", "Links útiles"),
BotCommand("cancelar", "Cancelar flujo actual"), BotCommand("cancelar", "Cancelar flujo actual"),
]) ])
@@ -69,6 +113,7 @@ def main():
app.add_handler(onboarding_handler) app.add_handler(onboarding_handler)
app.add_handler(vacaciones_handler) app.add_handler(vacaciones_handler)
app.add_handler(permiso_handler) app.add_handler(permiso_handler)
app.add_handler(CommandHandler("links", links_menu))
# app.add_handler(finder_handler) # app.add_handler(finder_handler)
print("🧠 Vanessa Bot Brain iniciada y lista para trabajar en todos los módulos.") print("🧠 Vanessa Bot Brain iniciada y lista para trabajar en todos los módulos.")

View File

@@ -1,4 +1,18 @@
import os import os
import importlib
import importlib.metadata as importlib_metadata
# Compatibilidad para entornos donde packages_distributions no existe (p.ej. Python 3.9 con importlib recortado).
if not hasattr(importlib_metadata, "packages_distributions"):
try:
import importlib_metadata as backport_metadata # type: ignore
if hasattr(backport_metadata, "packages_distributions"):
importlib_metadata.packages_distributions = backport_metadata.packages_distributions # type: ignore[attr-defined]
else:
importlib_metadata.packages_distributions = lambda: {} # type: ignore[assignment]
except Exception:
importlib_metadata.packages_distributions = lambda: {} # type: ignore[assignment]
import google.generativeai as genai import google.generativeai as genai
def classify_reason(text: str) -> str: def classify_reason(text: str) -> str:

View File

@@ -18,6 +18,7 @@ from telegram.ext import (
) )
from modules.database import log_request from modules.database import log_request
from modules.ui import main_actions_keyboard
# --- 1. CARGA DE ENTORNO --- # --- 1. CARGA DE ENTORNO ---
load_dotenv() # Carga las variables del archivo .env load_dotenv() # Carga las variables del archivo .env
@@ -304,8 +305,14 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
fecha_ini = "ERROR_FECHA" fecha_ini = "ERROR_FECHA"
# Derivados # Derivados
num_ext_texto = numero_a_texto(r.get(NUM_EXTERIOR, ""), r.get(NUM_INTERIOR, "")) num_ext_texto = numero_a_texto(r.get(NUM_EXTERIOR, ""), r.get(NUM_INTERIOR, ""))
curp_base = (r.get(CURP) or "").upper() # El número de empleado debe ser solo la fecha de inicio en formato AAMMDD.
n_empleado = f"{(curp_base + 'XXXX')[:4]}{fecha_ini.replace('-', '')}" try:
fecha_inicio_dt = datetime.strptime(fecha_ini, "%Y-%m-%d")
n_empleado = fecha_inicio_dt.strftime("%y%m%d")
except Exception:
# Fallback defensivo para no romper el flujo si viene un formato raro.
fecha_compacta = fecha_ini.replace("-", "")
n_empleado = fecha_compacta[-6:] if len(fecha_compacta) >= 6 else fecha_compacta or "N/A"
# PAYLOAD ESTRUCTURADO PARA N8N # PAYLOAD ESTRUCTURADO PARA N8N
payload = { payload = {
@@ -378,18 +385,22 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.message.reply_text( await update.message.reply_text(
"✅ *¡Registro Exitoso!*\n\n" "✅ *¡Registro Exitoso!*\n\n"
"Bienvenida a la familia Soul/Vanity. Tu contrato se está generando y te avisaremos pronto.\n" "Bienvenida a la familia Soul/Vanity. Tu contrato se está generando y te avisaremos pronto.\n"
"¡Nos vemos el primer día! ✨" "¡Nos vemos el primer día! ✨",
reply_markup=main_actions_keyboard()
) )
else: else:
await update.message.reply_text("⚠️ Se guardaron tus datos pero hubo un error de conexión. RH lo revisará manualmente.") await update.message.reply_text(
"⚠️ Se guardaron tus datos pero hubo un error de conexión. RH lo revisará manualmente.",
reply_markup=main_actions_keyboard()
)
context.user_data.clear() context.user_data.clear()
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:
await update.message.reply_text( await update.message.reply_text(
"Proceso cancelado. ⏸️\nCuando quieras retomar, escribe /contrato.", "Proceso cancelado. ⏸️\nPuedes retomarlo con /welcome o ir al menú con /start.",
reply_markup=ReplyKeyboardRemove() reply_markup=main_actions_keyboard()
) )
context.user_data.clear() context.user_data.clear()
return ConversationHandler.END return ConversationHandler.END

View File

@@ -5,6 +5,7 @@ from datetime import datetime, date
from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update
from telegram.ext import CommandHandler, ContextTypes, ConversationHandler, 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.ui import main_actions_keyboard
from modules.ai import classify_reason from modules.ai import classify_reason
# Helpers de webhooks # Helpers de webhooks
@@ -342,18 +343,30 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE)
f"- Estatus inicial: {payload.get('status_inicial', 'N/A')}" f"- Estatus inicial: {payload.get('status_inicial', 'N/A')}"
) )
if enviados > 0: if enviados > 0:
await update.message.reply_text(f"✅ Solicitud de *{tipo_solicitud_texto}* enviada a tu Manager.\n\n{resumen}") await update.message.reply_text(
f"✅ Solicitud de *{tipo_solicitud_texto}* enviada a tu Manager.\n\n{resumen}",
reply_markup=main_actions_keyboard()
)
else: else:
await update.message.reply_text(f"⚠️ No hay webhook configurado o falló el envío. RH lo revisará.\n\n{resumen}") await update.message.reply_text(
f"⚠️ No hay webhook configurado o falló el envío. RH lo revisará.\n\n{resumen}",
reply_markup=main_actions_keyboard()
)
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.",
reply_markup=main_actions_keyboard()
)
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:
await update.message.reply_text("Solicitud cancelada.") await update.message.reply_text(
"Solicitud cancelada. ⏸️\nPuedes volver a iniciar con /vacaciones o /permiso, o ir al menú con /start.",
reply_markup=main_actions_keyboard(),
)
return ConversationHandler.END return ConversationHandler.END
# Handlers separados pero comparten lógica # Handlers separados pero comparten lógica

13
modules/ui.py Normal file
View File

@@ -0,0 +1,13 @@
from telegram import ReplyKeyboardMarkup
def main_actions_keyboard() -> ReplyKeyboardMarkup:
"""Teclado inferior con comandos directos (un toque lanza el flujo)."""
return ReplyKeyboardMarkup(
[
["/welcome"],
["/vacaciones", "/permiso"],
["/links", "/start"],
],
resize_keyboard=True,
)