refactor: Implement dynamic conversation flow builder from JSON

This commit refactors the bot's architecture to dynamically load and build conversation flows from JSON files instead of hardcoding them in Python.

- Added  to read flow definitions from the  directory and dynamically build s.
- Refactored  to use the new flow builder and load all conversation handlers at startup.
- Moved hardcoded links to environment variables for better configuration.
- Updated  to support conditional branching for 'Other' options, using a  field to define state transitions.
- Updated  with the new link variables.
This commit is contained in:
Marco Gallegos
2025-12-20 09:55:14 -06:00
parent ce7769fe62
commit b68cb14837
4 changed files with 321 additions and 90 deletions

View File

@@ -13,6 +13,15 @@ WEBHOOK_PERMISOS=url
WEBHOOK_PRINTS=url WEBHOOK_PRINTS=url
WEBHOOK_SCHEDULE=url WEBHOOK_SCHEDULE=url
# ===============================
# LINKS
# ===============================
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
# =============================== # ===============================
# DATABASE SETUP # DATABASE SETUP
# =============================== # ===============================

View File

@@ -4,232 +4,318 @@
{ {
"state": 0, "state": 0,
"variable": "NOMBRE_SALUDO", "variable": "NOMBRE_SALUDO",
"question": "Para empezar con el pie derecho, ¿cómo te gusta que te llamemos?", "question": "¿Cómo te gusta que te llamemos?",
"type": "text" "type": "text",
"next_step": 1
}, },
{ {
"state": 1, "state": 1,
"variable": "NOMBRE_COMPLETO", "variable": "NOMBRE_COMPLETO",
"question": "¡Lindo nombre! ✨\n\nNecesito tus datos oficiales para el contrato.\n¿Cuáles son tus *nombres* (sin apellidos) tal cual aparecen en tu INE?", "question": "Escribe tus nombres (SIN apellidos), exactamente como aparecen en tu INE.",
"type": "text" "type": "text",
"next_step": 2
}, },
{ {
"state": 2, "state": 2,
"variable": "APELLIDO_PATERNO", "variable": "APELLIDO_PATERNO",
"question": "¿Cuál es tu *apellido paterno*?", "question": "Apellido paterno:",
"type": "text" "type": "text",
"next_step": 3
}, },
{ {
"state": 3, "state": 3,
"variable": "APELLIDO_MATERNO", "variable": "APELLIDO_MATERNO",
"question": "¿Y tu *apellido materno*?", "question": "Apellido materno:",
"type": "text" "type": "text",
"next_step": 4
}, },
{ {
"state": 4, "state": 4,
"variable": "CUMPLE_DIA", "variable": "CUMPLE_DIA",
"question": "🎂 Hablemos de ti. ¿Qué *día* es tu cumpleaños? (Escribe el número, ej: 13)", "question": "Fecha de nacimiento · Día (solo número, ej. 13)",
"type": "text" "type": "text",
"next_step": 5
}, },
{ {
"state": 5, "state": 5,
"variable": "CUMPLE_MES", "variable": "CUMPLE_MES",
"question": "¿De qué *mes*? 🎉", "question": "Fecha de nacimiento · Mes",
"type": "keyboard", "type": "keyboard",
"options": [ "options": ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"],
"Enero", "Febrero", "Marzo", "next_step": 6
"Abril", "Mayo", "Junio",
"Julio", "Agosto", "Septiembre",
"Octubre", "Noviembre", "Diciembre"
]
}, },
{ {
"state": 6, "state": 6,
"variable": "CUMPLE_ANIO", "variable": "CUMPLE_ANIO",
"question": "Entendido. ¿Y de qué *año*? 🗓️", "question": "Fecha de nacimiento · Año (4 dígitos)",
"type": "text" "type": "text",
"next_step": 7
}, },
{ {
"state": 7, "state": 7,
"variable": "ESTADO_NACIMIENTO", "variable": "ESTADO_NACIMIENTO",
"question": "🇲🇽 ¿En qué *estado de la república* naciste?", "question": "Estado de nacimiento\n\nSelecciona el estado donde naciste.\nSi no aparece, elige *Otro*.",
"type": "text" "type": "keyboard",
"options": ["Coahuila", "Nuevo León", "Otro"],
"next_steps": [
{ "value": "Otro", "go_to": 7.1 },
{ "value": "default", "go_to": 8 }
]
},
{
"state": 7.1,
"variable": "ESTADO_NACIMIENTO_OTRO",
"question": "Escribe el nombre del estado donde naciste.",
"type": "text",
"next_step": 8
}, },
{ {
"state": 8, "state": 8,
"variable": "RFC", "variable": "RFC",
"question": "Pasemos a lo administrativo 📄.\n\nPor favor escribe tu *RFC* (Sin espacios):", "question": "RFC completo (13 caracteres, sin espacios):",
"type": "text" "type": "text",
"next_step": 9
}, },
{ {
"state": 9, "state": 9,
"variable": "CURP", "variable": "CURP",
"question": "Gracias. Ahora tu *CURP*:", "question": "CURP completo (18 caracteres):",
"type": "text" "type": "text",
"next_step": 10
}, },
{ {
"state": 10, "state": 10,
"variable": "CORREO", "variable": "CORREO",
"question": "¡Súper! 📧 ¿A qué *correo electrónico* te enviamos la info?", "question": "Correo electrónico personal:",
"type": "text" "type": "text",
"next_step": 11
}, },
{ {
"state": 11, "state": 11,
"variable": "CELULAR", "variable": "CELULAR",
"question": "📱 ¿Cuál es tu número de *celular* personal? (10 dígitos)", "question": "Número de celular (10 dígitos):",
"type": "text" "type": "text",
"next_step": 12
}, },
{ {
"state": 12, "state": 12,
"variable": "CALLE", "variable": "CALLE",
"question": "🏠 Registremos tu domicilio.\n\n¿En qué *calle* vives?", "question": "Domicilio · Calle:",
"type": "text" "type": "text",
"next_step": 13
}, },
{ {
"state": 13, "state": 13,
"variable": "NUM_EXTERIOR", "variable": "NUM_EXTERIOR",
"question": "#️⃣ ¿Cuál es el *número exterior*?", "question": "Domicilio · Número exterior:",
"type": "text" "type": "text",
"next_step": 14
}, },
{ {
"state": 14, "state": 14,
"variable": "NUM_INTERIOR", "variable": "NUM_INTERIOR",
"question": "🚪 ¿Tienes *número interior*? (Escribe 0 si no aplica)", "question": "Domicilio · Número interior (0 si no aplica):",
"type": "text" "type": "text",
"next_step": 15
}, },
{ {
"state": 15, "state": 15,
"variable": "COLONIA", "variable": "COLONIA",
"question": "🏘️ ¿Cómo se llama la *colonia*?", "question": "Domicilio · Colonia:",
"type": "text" "type": "text",
"next_step": 16
}, },
{ {
"state": 16, "state": 16,
"variable": "CODIGO_POSTAL", "variable": "CODIGO_POSTAL",
"question": "📮 ¿Cuál es el *Código Postal*?", "question": "Código Postal (5 dígitos):",
"type": "text" "type": "text",
"next_step": 17
}, },
{ {
"state": 17, "state": 17,
"variable": "CIUDAD_RESIDENCIA", "variable": "CIUDAD_RESIDENCIA",
"question": "¿En qué *ciudad* resides actualmente?", "question": "Ciudad de residencia:",
"type": "keyboard", "type": "keyboard",
"options": ["Saltillo", "Ramos Arizpe", "Arteaga"] "options": ["Saltillo", "Ramos Arizpe", "Arteaga", "Otro"],
"next_steps": [
{ "value": "Otro", "go_to": 17.1 },
{ "value": "default", "go_to": 18 }
]
},
{
"state": 17.1,
"variable": "CIUDAD_RESIDENCIA_OTRO",
"question": "Escribe tu ciudad de residencia:",
"type": "text",
"next_step": 18
}, },
{ {
"state": 18, "state": 18,
"variable": "ROL", "variable": "ROL",
"question": "🔎 *Rol dentro del equipo*\nElige la opción que mejor describa tu posición:\n• *Belleza* — servicios de estética y spa\n• *Staff (Recepción)* — agenda y atención a clientes\n• *Marketing* — contenido, promos y comunidad\n\n_Toca un botón o escribe la opción:_", "question": "Rol dentro del equipo:",
"type": "keyboard", "type": "keyboard",
"options": ["Belleza", "Staff (Recepción)", "Marketing"] "options": ["Belleza", "Staff (Recepción)", "Marketing"],
"next_step": 19
}, },
{ {
"state": 19, "state": 19,
"variable": "SUCURSAL", "variable": "SUCURSAL",
"question": "¿A qué *sucursal* te vas a integrar? 📍", "question": "Sucursal principal:",
"type": "keyboard", "type": "keyboard",
"options": ["Plaza Cima (Sur) ⛰️", "Plaza O (Carranza) 🏙️"] "options": ["Plaza Cima (Sur)", "Plaza O (Carranza)"],
"next_step": 20
}, },
{ {
"state": 20, "state": 20,
"variable": "INICIO_DIA", "variable": "INICIO_DIA",
"question": "¡Qué emoción! 🎉\n\n¿Qué *día* está programado tu ingreso? (Solo el número, ej: 01)", "question": "Fecha de ingreso · Día:",
"type": "text" "type": "text",
"next_step": 21
}, },
{ {
"state": 21, "state": 21,
"variable": "INICIO_MES", "variable": "INICIO_MES",
"question": "¿De qué *mes* será tu ingreso?", "question": "Fecha de ingreso · Mes:",
"type": "keyboard", "type": "keyboard",
"options": [ "options": ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"],
"Enero", "Febrero", "Marzo", "next_step": 22
"Abril", "Mayo", "Junio",
"Julio", "Agosto", "Septiembre",
"Octubre", "Noviembre", "Diciembre"
]
}, },
{ {
"state": 22, "state": 22,
"variable": "INICIO_ANIO", "variable": "INICIO_ANIO",
"question": "¿Y de qué *año*?", "question": "Fecha de ingreso · Año:",
"type": "keyboard", "type": "keyboard",
"options": ["2020", "2021", "2022", "2023", "2024", "2025", "2026"] "options": ["2024", "2025", "2026"],
"next_step": 23
}, },
{ {
"state": 23, "state": 23,
"variable": "REF1_NOMBRE", "variable": "REF1_NOMBRE",
"question": "Ya casi acabamos. Necesito 3 referencias.\n\n👤 *Referencia 1*: Nombre completo", "question": "Referencia 1 · Nombre completo:",
"type": "text" "type": "text",
"next_step": 24
}, },
{ {
"state": 24, "state": 24,
"variable": "REF1_TELEFONO", "variable": "REF1_TELEFONO",
"question": "📞 Teléfono de la Referencia 1:", "question": "Referencia 1 · Teléfono:",
"type": "text" "type": "text",
"next_step": 25
}, },
{ {
"state": 25, "state": 25,
"variable": "REF1_TIPO", "variable": "REF1_TIPO",
"question": "🧑‍🤝‍🧑 ¿Qué relación tienes con ella/él?", "question": "Referencia 1 · Relación:",
"type": "keyboard", "type": "keyboard",
"options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"] "options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"],
"next_steps": [
{ "value": "Otra", "go_to": 25.1 },
{ "value": "default", "go_to": 26 }
]
},
{
"state": 25.1,
"variable": "REF1_TIPO_OTRA",
"question": "Especifíca la relación con la Referencia 1:",
"type": "text",
"next_step": 26
}, },
{ {
"state": 26, "state": 26,
"variable": "REF2_NOMBRE", "variable": "REF2_NOMBRE",
"question": "Ok. Vamos con la *Referencia 2*.\n\n👤 Nombre completo:", "question": "Referencia 2 · Nombre completo:",
"type": "text" "type": "text",
"next_step": 27
}, },
{ {
"state": 27, "state": 27,
"variable": "REF2_TELEFONO", "variable": "REF2_TELEFONO",
"question": "📞 Teléfono de la Referencia 2:", "question": "Referencia 2 · Teléfono:",
"type": "text" "type": "text",
"next_step": 28
}, },
{ {
"state": 28, "state": 28,
"variable": "REF2_TIPO", "variable": "REF2_TIPO",
"question": "🧑‍🤝‍🧑 ¿Qué relación tienen?", "question": "Referencia 2 · Relación:",
"type": "keyboard", "type": "keyboard",
"options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"] "options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"],
"next_steps": [
{ "value": "Otra", "go_to": 28.1 },
{ "value": "default", "go_to": 29 }
]
},
{
"state": 28.1,
"variable": "REF2_TIPO_OTRA",
"question": "Especifíca la relación con la Referencia 2:",
"type": "text",
"next_step": 29
}, },
{ {
"state": 29, "state": 29,
"variable": "REF3_NOMBRE", "variable": "REF3_NOMBRE",
"question": "Última. *Referencia 3*.\n\n👤 Nombre completo:", "question": "Referencia 3 · Nombre completo:",
"type": "text" "type": "text",
"next_step": 30
}, },
{ {
"state": 30, "state": 30,
"variable": "REF3_TELEFONO", "variable": "REF3_TELEFONO",
"question": "📞 Teléfono de la Referencia 3:", "question": "Referencia 3 · Teléfono:",
"type": "text" "type": "text",
"next_step": 31
}, },
{ {
"state": 31, "state": 31,
"variable": "REF3_TIPO", "variable": "REF3_TIPO",
"question": "🧑‍🤝‍🧑 ¿Qué relación tienen?", "question": "Referencia 3 · Relación:",
"type": "keyboard", "type": "keyboard",
"options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"] "options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"],
"next_steps": [
{ "value": "Otra", "go_to": 31.1 },
{ "value": "default", "go_to": 32 }
]
},
{
"state": 31.1,
"variable": "REF3_TIPO_OTRA",
"question": "Especifíca la relación con la Referencia 3:",
"type": "text",
"next_step": 32
}, },
{ {
"state": 32, "state": 32,
"variable": "EMERGENCIA_NOMBRE", "variable": "EMERGENCIA_NOMBRE",
"question": "Finalmente, por seguridad 🚑:\n\n¿A quién llamamos en caso de *emergencia*?", "question": "Contacto de emergencia · Nombre completo:",
"type": "text" "type": "text",
"next_step": 33
}, },
{ {
"state": 33, "state": 33,
"variable": "EMERGENCIA_TEL", "variable": "EMERGENCIA_TEL",
"question": "☎️ ¿Cuál es el teléfono de esa persona?", "question": "Contacto de emergencia · Teléfono:",
"type": "text" "type": "text",
"next_step": 34
}, },
{ {
"state": 34, "state": 34,
"variable": "EMERGENCIA_RELACION", "variable": "EMERGENCIA_RELACION",
"question": "¿Qué parentesco tiene contigo?", "question": "Relación con el contacto de emergencia:",
"type": "keyboard", "type": "keyboard",
"options": ["Padre/Madre", "Esposo/a","Pareja", "Hijo/a", "Hermano/a", "Amigo/a", "Otro"] "options": ["Padre/Madre", "Esposo/a", "Pareja", "Hijo/a", "Hermano/a", "Amigo/a", "Otro"],
"next_steps": [
{ "value": "Otro", "go_to": 34.1 },
{ "value": "default", "go_to": -1 }
]
},
{
"state": 34.1,
"variable": "EMERGENCIA_RELACION_OTRA",
"question": "Especifíca la relación con el contacto de emergencia:",
"type": "text",
"next_step": -1
} }
] ]
} }

20
main.py
View File

@@ -16,17 +16,18 @@ 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.flow_builder import load_flows
from modules.rh_requests import vacaciones_handler, permiso_handler
from modules.logger import log_request from modules.logger import log_request
from modules.database import chat_id_exists # Importar chat_id_exists from modules.database import chat_id_exists # Importar chat_id_exists
from modules.ui import main_actions_keyboard 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/" # Cargar links desde variables de entorno
LINK_SITIO = "https://vanityexperience.mx/" LINK_CURSOS = os.getenv("LINK_CURSOS", "https://cursos.vanityexperience.mx/dashboard-2/")
LINK_AGENDA_IOS = "https://apps.apple.com/us/app/fresha-for-business/id1455346253" LINK_SITIO = os.getenv("LINK_SITIO", "https://vanityexperience.mx/")
LINK_AGENDA_ANDROID = "https://play.google.com/store/apps/details?id=com.fresha.Business" LINK_AGENDA_IOS = os.getenv("LINK_AGENDA_IOS", "https://apps.apple.com/us/app/fresha-for-business/id1455346253")
LINK_AGENDA_ANDROID = os.getenv("LINK_AGENDA_ANDROID", "https://play.google.com/store/apps/details?id=com.fresha.Business")
TOKEN = os.getenv("TELEGRAM_TOKEN") TOKEN = os.getenv("TELEGRAM_TOKEN")
@@ -112,9 +113,10 @@ def main():
app.add_handler(CommandHandler("help", menu_principal)) app.add_handler(CommandHandler("help", menu_principal))
# 2. Habilidades Complejas (Conversaciones) # 2. Habilidades Complejas (Conversaciones)
app.add_handler(onboarding_handler) flow_handlers = load_flows()
app.add_handler(vacaciones_handler) for handler in flow_handlers:
app.add_handler(permiso_handler) app.add_handler(handler)
app.add_handler(CommandHandler("links", links_menu)) app.add_handler(CommandHandler("links", links_menu))
# app.add_handler(finder_handler) # app.add_handler(finder_handler)

134
modules/flow_builder.py Normal file
View File

@@ -0,0 +1,134 @@
import json
import os
import logging
from functools import partial
from telegram import Update, ReplyKeyboardRemove, ReplyKeyboardMarkup
from telegram.ext import (
ContextTypes,
ConversationHandler,
MessageHandler,
CommandHandler,
filters,
)
# Assuming finalization logic will be handled elsewhere for now
# from .onboarding import finalizar, cancelar
# A simple end state for now
async def end(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.message.reply_text("Flow ended.")
return ConversationHandler.END
async def generic_callback(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict):
current_state_key = context.user_data.get("current_state", 0)
# Find the current step in the flow
current_step = next((step for step in flow["steps"] if step["state"] == current_state_key), None)
if not current_step:
await update.message.reply_text("Hubo un error en el flujo. Por favor, inicia de nuevo.")
return ConversationHandler.END
# Save the answer
user_answer = update.message.text
variable_name = current_step.get("variable")
if variable_name:
context.user_data[variable_name] = user_answer
# Determine the next state
next_state_key = None
if "next_steps" in current_step:
for condition in current_step["next_steps"]:
if condition["value"] == user_answer:
next_state_key = condition["go_to"]
break
elif condition["value"] == "default":
next_state_key = condition["go_to"]
elif "next_step" in current_step:
next_state_key = current_step["next_step"]
if next_state_key is None:
# If no next step is defined, end the conversation
return await end(update, context)
# Find the next step
next_step = next((step for step in flow["steps"] if step["state"] == next_state_key), None)
if not next_step:
# If the next step is the end of the conversation
if next_state_key == -1:
# Here we would call the generic "finalizar" function
# For now, just end it
await update.message.reply_text("Has completado el flujo. ¡Gracias!")
# return await finalizar(update, context)
return ConversationHandler.END
else:
await update.message.reply_text("Error: No se encontró el siguiente paso del flujo.")
return ConversationHandler.END
# Ask the next question
reply_markup = ReplyKeyboardRemove()
if next_step.get("type") == "keyboard" and "options" in next_step:
reply_markup = ReplyKeyboardMarkup(
[next_step["options"][i:i+3] for i in range(0, len(next_step["options"]), 3)],
one_time_keyboard=True, resize_keyboard=True
)
await update.message.reply_text(next_step["question"], reply_markup=reply_markup)
# Update the current state
context.user_data["current_state"] = next_state_key
return next_state_key
async def start_flow(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict):
context.user_data.clear()
context.user_data["flow_name"] = flow["flow_name"]
# Start with the first step
first_step = flow["steps"][0]
context.user_data["current_state"] = first_step["state"]
reply_markup = ReplyKeyboardRemove()
if first_step.get("type") == "keyboard" and "options" in first_step:
reply_markup = ReplyKeyboardMarkup(
[first_step["options"][i:i+3] for i in range(0, len(first_step["options"]), 3)],
one_time_keyboard=True, resize_keyboard=True
)
await update.message.reply_text(first_step["question"], reply_markup=reply_markup)
return first_step["state"]
def create_handler(flow: dict):
states = {}
for step in flow["steps"]:
callback = partial(generic_callback, flow=flow)
states[step["state"]] = [MessageHandler(filters.TEXT & ~filters.COMMAND, callback)]
# The entry point should be a command with the same name as the flow
entry_point = CommandHandler(flow["flow_name"], partial(start_flow, flow=flow))
return ConversationHandler(
entry_points=[entry_point],
states=states,
fallbacks=[CommandHandler("cancelar", end)], # Replace with generic cancel
allow_reentry=True,
)
def load_flows():
flow_handlers = []
for filename in os.listdir("conv-flows"):
if filename.endswith(".json"):
filepath = os.path.join("conv-flows", filename)
with open(filepath, "r", encoding="utf-8") as f:
try:
flow_definition = json.load(f)
handler = create_handler(flow_definition)
flow_handlers.append(handler)
logging.info(f"Flow '{flow_definition['flow_name']}' loaded successfully.")
except json.JSONDecodeError as e:
logging.error(f"Error decoding JSON from {filename}: {e}")
except Exception as e:
logging.error(f"Error creating handler for {filename}: {e}")
return flow_handlers