diff --git a/app/calendar.py b/app/calendar.py index 440b4ee..00d38c1 100644 --- a/app/calendar.py +++ b/app/calendar.py @@ -1,4 +1,6 @@ # app/calendar.py +# Este script maneja la integración con Google Calendar (Calendario de Google). +# Permite buscar espacios libres y crear eventos. import datetime from google.oauth2 import service_account @@ -6,11 +8,15 @@ from googleapiclient.discovery import build from googleapiclient.errors import HttpError from config import GOOGLE_SERVICE_ACCOUNT_FILE, CALENDAR_ID -# Set up the Calendar API +# Configuración de los permisos (SCOPES) para acceder al calendario SCOPES = ["https://www.googleapis.com/auth/calendar"] + +# Autenticación usando el archivo de cuenta de servicio (Service Account) creds = service_account.Credentials.from_service_account_file( GOOGLE_SERVICE_ACCOUNT_FILE, scopes=SCOPES ) + +# Construcción del objeto 'service' que nos permite interactuar con la API de Google Calendar service = build("calendar", "v3", credentials=creds) @@ -18,12 +24,20 @@ def get_available_slots( start_time, end_time, duration_minutes=30, calendar_id=CALENDAR_ID ): """ - Fetches available calendar slots within a given time range. + Busca espacios disponibles en el calendario dentro de un rango de tiempo. + + Parámetros: + - start_time: Hora de inicio de la búsqueda. + - end_time: Hora de fin de la búsqueda. + - duration_minutes: Cuánto dura cada cita (por defecto 30 min). + - calendar_id: El ID del calendario donde buscar. """ try: + # Convertimos las fechas a formato ISO (el que entiende Google) time_min = start_time.isoformat() time_max = end_time.isoformat() + # Consultamos a Google qué horas están ocupadas (freebusy) freebusy_query = { "timeMin": time_min, "timeMax": time_max, @@ -34,7 +48,7 @@ def get_available_slots( freebusy_result = service.freebusy().query(body=freebusy_query).execute() busy_slots = freebusy_result["calendars"][calendar_id]["busy"] - # Create a list of all potential slots + # Creamos una lista de todos los posibles espacios (slots) potential_slots = [] current_time = start_time while current_time + datetime.timedelta(minutes=duration_minutes) <= end_time: @@ -44,15 +58,17 @@ def get_available_slots( current_time + datetime.timedelta(minutes=duration_minutes), ) ) + # Avanzamos el tiempo para el siguiente espacio current_time += datetime.timedelta(minutes=duration_minutes) - # Filter out busy slots + # Filtramos los espacios que chocan con horas ocupadas available_slots = [] for slot_start, slot_end in potential_slots: is_busy = False for busy in busy_slots: busy_start = datetime.datetime.fromisoformat(busy["start"]) busy_end = datetime.datetime.fromisoformat(busy["end"]) + # Si el espacio propuesto se cruza con uno ocupado, lo marcamos como ocupado if max(slot_start, busy_start) < min(slot_end, busy_end): is_busy = True break @@ -61,14 +77,21 @@ def get_available_slots( return available_slots except HttpError as error: - print(f"An error occurred: {error}") + print(f"Ocurrió un error con la API de Google: {error}") return [] def create_event(summary, start_time, end_time, attendees, calendar_id=CALENDAR_ID): """ - Creates a new event in the calendar. + Crea un nuevo evento (cita) en el calendario. + + Parámetros: + - summary: Título del evento. + - start_time: Hora de inicio. + - end_time: Hora de fin. + - attendees: Lista de correos electrónicos de los asistentes. """ + # Definimos la estructura del evento según pide Google event = { "summary": summary, "start": { @@ -82,10 +105,11 @@ def create_event(summary, start_time, end_time, attendees, calendar_id=CALENDAR_ "attendees": [{"email": email} for email in attendees], } try: + # Insertamos el evento en el calendario created_event = ( service.events().insert(calendarId=calendar_id, body=event).execute() ) return created_event except HttpError as error: - print(f"An error occurred: {error}") + print(f"Ocurrió un error al crear el evento: {error}") return None diff --git a/app/config.py b/app/config.py index c8d9c9b..708268c 100644 --- a/app/config.py +++ b/app/config.py @@ -1,12 +1,32 @@ # app/config.py +# Este archivo se encarga de cargar todas las variables de entorno y configuraciones del bot. +# Las variables de entorno son valores que se guardan fuera del código por seguridad (como tokens y llaves API). + import os +# Token del bot de Telegram (obtenido de @BotFather) TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") + +# ID de chat del dueño del bot (para recibir notificaciones importantes) OWNER_CHAT_ID = os.getenv("OWNER_CHAT_ID") + +# IDs de chat de los administradores, separados por comas en el archivo .env ADMIN_CHAT_IDS = os.getenv("ADMIN_CHAT_IDS", "").split(",") + +# IDs de chat del equipo de trabajo, separados por comas TEAM_CHAT_IDS = os.getenv("TEAM_CHAT_IDS", "").split(",") + +# Ruta al archivo de credenciales de la cuenta de servicio de Google GOOGLE_SERVICE_ACCOUNT_FILE = os.getenv("GOOGLE_SERVICE_ACCOUNT_FILE") + +# ID del calendario de Google que usará el bot CALENDAR_ID = os.getenv("CALENDAR_ID") + +# URL del webhook de n8n para enviar datos a otros servicios N8N_WEBHOOK_URL = os.getenv("N8N_WEBHOOK_URL") + +# Llave de la API de OpenAI para usar modelos de lenguaje (como GPT) OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") + +# Zona horaria por defecto para el manejo de fechas y horas TIMEZONE = os.getenv("TIMEZONE", "America/Mexico_City") diff --git a/app/llm.py b/app/llm.py index 3b97357..547c11f 100644 --- a/app/llm.py +++ b/app/llm.py @@ -1,23 +1,34 @@ # app/llm.py +# Este script se encarga de la comunicación con la inteligencia artificial de OpenAI. + import openai from config import OPENAI_API_KEY def get_smart_response(prompt): """ - Generates a smart response using the OpenAI API. + Genera una respuesta inteligente usando la API de OpenAI. + + Parámetros: + - prompt: El texto o pregunta que le enviamos a la IA. """ + # Verificamos que tengamos la llave de la API configurada if not OPENAI_API_KEY: - return "Error: OpenAI API key is not configured." + return "Error: La llave de la API de OpenAI no está configurada." try: + # Creamos el cliente de OpenAI client = openai.OpenAI(api_key=OPENAI_API_KEY) + + # Solicitamos una respuesta al modelo GPT-3.5-turbo response = client.chat.completions.create( model="gpt-3.5-turbo", messages=[ - {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "Eres un asistente útil."}, {"role": "user", "content": prompt}, ], ) + # Devolvemos el contenido de la respuesta limpia (sin espacios extras) return response.choices[0].message.content.strip() except Exception as e: - return f"An error occurred while communicating with OpenAI: {e}" + # Si algo sale mal, devolvemos el error + return f"Ocurrió un error al comunicarse con OpenAI: {e}" diff --git a/app/main.py b/app/main.py index 5319833..4ce808d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,6 @@ # app/main.py +# Este es el archivo principal del bot. Aquí se inicia todo y se configuran los comandos. + import logging from telegram import Update from telegram.ext import ( @@ -11,6 +13,7 @@ from telegram.ext import ( filters, ) +# Importamos las configuraciones y herramientas que creamos en otros archivos from config import TELEGRAM_BOT_TOKEN from permissions import get_user_role from modules.onboarding import handle_start as onboarding_handle_start @@ -32,34 +35,42 @@ from modules.print import print_handler from modules.create_tag import create_tag_conv_handler from scheduler import schedule_daily_summary -# Enable logging +# Configuramos el sistema de logs para ver mensajes de estado en la consola logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) logger = logging.getLogger(__name__) async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Sends a welcome message and menu when the /start command is issued.""" + """ + Se ejecuta cuando el usuario escribe /start. + Muestra un mensaje de bienvenida y un menú según el rol del usuario. + """ chat_id = update.effective_chat.id user_role = get_user_role(chat_id) - logger.info(f"User {chat_id} started conversation with role: {user_role}") + logger.info(f"Usuario {chat_id} inició conversación con el rol: {user_role}") + # Obtenemos el texto y los botones de bienvenida desde el módulo de onboarding response_text, reply_markup = onboarding_handle_start(user_role) + # Respondemos al usuario await update.message.reply_text(response_text, reply_markup=reply_markup) async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Parses the CallbackQuery and routes it to the appropriate handler.""" + """ + Esta función maneja los clics en los botones del menú. + Dependiendo de qué botón se presione, ejecuta una acción diferente. + """ query = update.callback_query - await query.answer() - logger.info(f"Dispatcher received callback query: {query.data}") + await query.answer() # Avisa a Telegram que recibimos el clic + logger.info(f"El despachador recibió una consulta: {query.data}") - # Default response if no handler is found + # Texto por defecto si no encontramos la acción response_text = "Acción no reconocida." reply_markup = None - # Simple callbacks that return a string + # Diccionario de acciones simples (que solo devuelven texto) simple_handlers = { 'view_agenda': get_agenda, 'view_requests_status': view_requests_status, @@ -69,32 +80,38 @@ async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE) 'manage_users': lambda: "Función de gestión de usuarios no implementada.", } - # Callbacks that return a tuple (text, reply_markup) + # Diccionario de acciones complejas (que devuelven texto y botones) complex_handlers = { 'view_pending': view_pending, } + # Buscamos qué función ejecutar según el dato del botón (query.data) if query.data in simple_handlers: response_text = simple_handlers[query.data]() elif query.data in complex_handlers: response_text, reply_markup = complex_handlers[query.data]() elif query.data.startswith(('approve:', 'reject:')): + # Manejo especial para botones de aprobar o rechazar response_text = handle_approval_action(query.data) + # Editamos el mensaje original con la nueva información await query.edit_message_text(text=response_text, reply_markup=reply_markup, parse_mode='Markdown') def main() -> None: - """Start the bot.""" + """Función principal que arranca el bot.""" + # Verificamos que tengamos el token del bot if not TELEGRAM_BOT_TOKEN: - logger.error("TELEGRAM_BOT_TOKEN is not set in the environment variables.") + logger.error("TELEGRAM_BOT_TOKEN no está configurado en las variables de entorno.") return + # Creamos la aplicación del bot application = Application.builder().token(TELEGRAM_BOT_TOKEN).build() - # Schedule daily summary + # Programamos el resumen diario schedule_daily_summary(application) - # Conversation handler for proposing activities + # Configuramos un "manejador de conversación" para proponer actividades + # Esto permite que el bot haga varias preguntas seguidas (descripción, duración) conv_handler = ConversationHandler( entry_points=[CallbackQueryHandler(propose_activity_start, pattern='^propose_activity$')], states={ @@ -105,14 +122,17 @@ def main() -> None: per_message=False ) + # Registramos todos los manejadores de eventos en la aplicación application.add_handler(conv_handler) application.add_handler(create_tag_conv_handler()) application.add_handler(CommandHandler("start", start)) application.add_handler(CommandHandler("print", print_handler)) application.add_handler(CallbackQueryHandler(button_dispatcher)) - logger.info("Starting Talía Bot...") + # Iniciamos el bot (se queda escuchando mensajes) + logger.info("Iniciando Talía Bot...") application.run_polling() +# Si este archivo se ejecuta directamente, llamamos a la función main() if __name__ == "__main__": main() diff --git a/app/modules/admin.py b/app/modules/admin.py index 14ef553..d5c530d 100644 --- a/app/modules/admin.py +++ b/app/modules/admin.py @@ -1,18 +1,15 @@ # app/modules/admin.py -""" -This module contains administrative functions for the bot. - -Currently, it provides a simple way to check the system's status. -""" +# Este módulo contiene funciones administrativas para el bot. +# Por ahora, permite ver el estado general del sistema. def get_system_status(): """ - Returns a formatted string with the current status of the bot and its integrations. - - This function currently returns a hardcoded status message. In the future, - it could be expanded to perform real-time checks on the different services. + Devuelve un mensaje con el estado actual del bot y sus conexiones. + + Actualmente el mensaje es fijo (hardcoded), pero en el futuro podría + hacer pruebas reales de conexión. """ - # TODO: Implement real-time status checks for more accurate monitoring. + # TODO: Implementar pruebas de estado en tiempo real para un monitoreo exacto. status_text = ( "📊 *Estado del Sistema*\n\n" "- *Bot Principal:* Activo ✅\n" diff --git a/app/modules/agenda.py b/app/modules/agenda.py index 4b78bc7..e921066 100644 --- a/app/modules/agenda.py +++ b/app/modules/agenda.py @@ -1,19 +1,15 @@ # app/modules/agenda.py -""" -This module is responsible for handling agenda-related requests. - -It provides functionality to fetch and display the user's schedule for the day. -""" +# Este módulo se encarga de manejar las peticiones relacionadas con la agenda. +# Permite obtener y mostrar las actividades programadas para el día. def get_agenda(): """ - Fetches and displays the user's agenda for the current day. - - Currently, this function returns a hardcoded sample agenda for demonstration - purposes. The plan is to replace this with a real integration that fetches - events from a service like Google Calendar. + Obtiene y muestra la agenda del usuario para el día actual. + + Por ahora, esta función devuelve una agenda de ejemplo fija. + El plan es conectarla con Google Calendar para que sea real. """ - # TODO: Fetch the agenda dynamically from Google Calendar. + # TODO: Obtener la agenda dinámicamente desde Google Calendar. agenda_text = ( "📅 *Agenda para Hoy*\n\n" "• *10:00 AM - 11:00 AM*\n" diff --git a/app/modules/aprobaciones.py b/app/modules/aprobaciones.py index 1060511..1099d47 100644 --- a/app/modules/aprobaciones.py +++ b/app/modules/aprobaciones.py @@ -1,22 +1,19 @@ # app/modules/aprobaciones.py -""" -This module manages the approval workflow for requests made by the team. +# Este módulo gestiona el flujo de aprobación para las solicitudes hechas por el equipo. +# Permite ver solicitudes pendientes y aprobarlas o rechazarlas. +# El usuario principal aquí es el "owner" (dueño). -It provides functions to view pending requests and to handle the approval or -rejection of those requests. The primary user for this module is the "owner" -role, who has the authority to approve or deny requests. -""" from telegram import InlineKeyboardButton, InlineKeyboardMarkup def get_approval_menu(request_id): """ - Creates and returns an inline keyboard with "Approve" and "Reject" buttons. - - Each button is associated with a specific request_id through the - callback_data, allowing the bot to identify which request is being acted upon. + Crea un menú de botones (teclado en línea) con "Aprobar" y "Rechazar". + + Cada botón lleva el ID de la solicitud para saber cuál estamos procesando. """ keyboard = [ [ + # callback_data es lo que el bot recibe cuando se pulsa el botón InlineKeyboardButton("✅ Aprobar", callback_data=f'approve:{request_id}'), InlineKeyboardButton("❌ Rechazar", callback_data=f'reject:{request_id}'), ] @@ -25,13 +22,11 @@ def get_approval_menu(request_id): def view_pending(): """ - Shows the owner a list of pending requests that require their attention. - - Currently, this function uses a hardcoded list of proposals for demonstration. - In a production environment, this would fetch data from a database or another - persistent storage mechanism where pending requests are tracked. + Muestra al dueño una lista de solicitudes que esperan su aprobación. + + Por ahora usa una lista fija de ejemplo. """ - # TODO: Fetch pending requests dynamically from a database or webhook events. + # TODO: Obtener solicitudes reales desde una base de datos o servicio externo. proposals = [ {"id": "prop_001", "desc": "Grabación de proyecto", "duration": 4, "user": "Equipo A"}, {"id": "prop_002", "desc": "Taller de guion", "duration": 2, "user": "Equipo B"}, @@ -40,7 +35,7 @@ def view_pending(): if not proposals: return "No hay solicitudes pendientes.", None - # For demonstration purposes, we'll just show the first pending proposal. + # Tomamos la primera propuesta para mostrarla proposal = proposals[0] text = ( @@ -50,28 +45,25 @@ def view_pending(): f"⏳ *Duración:* {proposal['duration']} horas" ) - # Attach the approval menu to the message. + # Adjuntamos los botones de aprobación reply_markup = get_approval_menu(proposal['id']) return text, reply_markup def handle_approval_action(callback_data): """ - Handles the owner's response (approve or reject) to a request. - - This function is triggered when the owner clicks one of the buttons created - by get_approval_menu. It parses the callback_data to determine the action - and the request ID. + Maneja la respuesta del dueño (clic en aprobar o rechazar). + + Separa la acción (approve/reject) del ID de la solicitud. """ + # callback_data viene como "accion:id", por ejemplo "approve:prop_001" action, request_id = callback_data.split(':') if action == 'approve': - # TODO: Implement logic to update the request's status to 'approved'. - # This could involve updating a database and notifying the requester. + # TODO: Guardar en base de datos que fue aprobada y avisar al equipo. return f"✅ La solicitud *{request_id}* ha sido aprobada." elif action == 'reject': - # TODO: Implement logic to update the request's status to 'rejected'. - # This could involve updating a database and notifying the requester. + # TODO: Guardar en base de datos que fue rechazada y avisar al equipo. return f"❌ La solicitud *{request_id}* ha sido rechazada." return "Acción desconocida.", None diff --git a/app/modules/citas.py b/app/modules/citas.py index a0e40d7..a05d66e 100644 --- a/app/modules/citas.py +++ b/app/modules/citas.py @@ -1,21 +1,15 @@ # app/modules/citas.py -""" -This module handles appointment scheduling for clients. - -It provides a simple way for users to get a link to an external scheduling -service, such as Calendly or an n8n workflow. -""" +# Este módulo maneja la programación de citas para los clientes. +# Permite a los usuarios obtener un enlace para agendar una reunión. def request_appointment(): """ - Provides the user with a link to schedule an appointment. - - Currently, this function returns a hardcoded placeholder link to Calendly. - The intention is to replace this with a dynamic link generated by an n8n - workflow or another scheduling service. + Proporciona al usuario un enlace para agendar una cita. + + Por ahora devuelve un enlace de ejemplo a Calendly. + La idea es que sea un enlace dinámico generado por n8n. """ - # TODO: Integrate with a real scheduling service or an n8n workflow to - # provide a dynamic and personalized scheduling link. + # TODO: Integrar con un servicio real o un flujo de n8n para dar un enlace personalizado. response_text = ( "Para agendar una cita, por favor utiliza el siguiente enlace: \n\n" "[Enlace de Calendly](https://calendly.com/user/appointment-link)" diff --git a/app/modules/create_tag.py b/app/modules/create_tag.py index b1b5241..e145868 100644 --- a/app/modules/create_tag.py +++ b/app/modules/create_tag.py @@ -1,12 +1,8 @@ # app/modules/create_tag.py -""" -This module contains the functionality for the /create_tag command. +# Este módulo permite crear un "tag" (etiqueta) con información del empleado. +# Usa un ConversationHandler para hacer una serie de preguntas al usuario. +# Al final, genera un código en Base64 que contiene toda la información en formato JSON. -It uses a ConversationHandler to guide the user through a series of questions -to collect data (name, employee number, branch, and Telegram ID), and then -generates a Base64-encoded JSON string from that data. This string is intended -to be used for creating an NFC tag. -""" import base64 import json import logging @@ -19,62 +15,56 @@ from telegram.ext import ( filters, ) -# Enable logging to monitor the bot's operation and for debugging. +# Configuramos los logs para este archivo logger = logging.getLogger(__name__) -# Define the states for the conversation. These states are used to track the -# user's progress through the conversation and determine which handler function -# should be executed next. +# Definimos los estados de la conversación. +# Cada número representa un paso en el proceso de preguntas. NAME, NUM_EMP, SUCURSAL, TELEGRAM_ID = range(4) async def create_tag_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """ - Starts the conversation to create a new tag when the /create_tag command - is issued. It prompts the user for the first piece of information (name). + Inicia el proceso cuando el usuario escribe /create_tag. + Pide el primer dato: el nombre. """ await update.message.reply_text("Vamos a crear un nuevo tag. Por favor, dime el nombre:") - # The function returns the next state, which is NAME, so the conversation - # knows which handler to call next. + # Devolvemos el siguiente estado: NAME return NAME async def get_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """ - Stores the user's provided name in the context and then asks for the - next piece of information, the employee number. + Guarda el nombre y pide el número de empleado. """ context.user_data['name'] = update.message.text await update.message.reply_text("Gracias. Ahora, por favor, dime el número de empleado:") - # The function returns the next state, NUM_EMP. + # Devolvemos el siguiente estado: NUM_EMP return NUM_EMP async def get_num_emp(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """ - Stores the employee number and proceeds to ask for the branch name. + Guarda el número de empleado y pide la sucursal. """ context.user_data['num_emp'] = update.message.text await update.message.reply_text("Entendido. Ahora, por favor, dime la sucursal:") - # The function returns the next state, SUCURSAL. + # Devolvemos el siguiente estado: SUCURSAL return SUCURSAL async def get_sucursal(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """ - Stores the branch name and asks for the final piece of information, - the user's Telegram ID. + Guarda la sucursal y pide el ID de Telegram. """ context.user_data['sucursal'] = update.message.text await update.message.reply_text("Perfecto. Finalmente, por favor, dime el ID de Telegram:") - # The function returns the next state, TELEGRAM_ID. + # Devolvemos el siguiente estado: TELEGRAM_ID return TELEGRAM_ID async def get_telegram_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """ - Stores the Telegram ID, assembles all the collected data into a JSON - object, encodes it into a Base64 string, and sends the result back to - the user. This function concludes the conversation. + Guarda el ID de Telegram, junta todos los datos y genera el código Base64. """ context.user_data['telegram_id'] = update.message.text - # Create a dictionary from the data collected and stored in user_data. + # Creamos un diccionario (como una caja con etiquetas) con todos los datos tag_data = { "name": context.user_data.get('name'), "num_emp": context.user_data.get('num_emp'), @@ -82,28 +72,27 @@ async def get_telegram_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> "telegram_id": context.user_data.get('telegram_id'), } - # Convert the Python dictionary into a JSON formatted string. + # Convertimos el diccionario a una cadena de texto en formato JSON json_string = json.dumps(tag_data) - # Encode the JSON string into Base64. The string is first encoded to - # UTF-8 bytes, which is then encoded to Base64 bytes, and finally - # decoded back to a UTF-8 string for display. + # Convertimos esa cadena a Base64 (un formato que se puede guardar en tags NFC) + # 1. Codificamos a bytes (utf-8) + # 2. Codificamos esos bytes a base64 + # 3. Convertimos de vuelta a texto para mostrarlo base64_bytes = base64.b64encode(json_string.encode('utf-8')) base64_string = base64_bytes.decode('utf-8') await update.message.reply_text(f"¡Gracias! Aquí está tu tag en formato Base64:\n\n`{base64_string}`", parse_mode='Markdown') - # Clean up the user_data dictionary to ensure no data from this - # conversation is accidentally used in another one. + # Limpiamos los datos temporales del usuario context.user_data.clear() - # End the conversation. + # Terminamos la conversación return ConversationHandler.END async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """ - Cancels and ends the conversation if the user issues the /cancel command. - It also clears any data that has been collected so far. + Cancela el proceso si el usuario escribe /cancel. """ await update.message.reply_text("Creación de tag cancelada.") context.user_data.clear() @@ -111,19 +100,13 @@ async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: def create_tag_conv_handler(): """ - Creates and returns a ConversationHandler for the /create_tag command. - This handler manages the entire conversational flow, from starting the - conversation to handling user inputs and ending the conversation. + Configura el manejador de la conversación (el flujo de preguntas). """ return ConversationHandler( - # The entry_points list defines how the conversation can be started. - # In this case, it's started by the /create_tag command. + # Punto de entrada: el comando /create_tag entry_points=[CommandHandler('create_tag', create_tag_start)], - # The states dictionary maps the conversation states to their - # respective handler functions. When the conversation is in a - # particular state, the corresponding handler is called to process - # the user's message. + # Mapa de estados: qué función responde a cada paso states={ NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_name)], NUM_EMP: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_num_emp)], @@ -131,12 +114,8 @@ def create_tag_conv_handler(): TELEGRAM_ID: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_telegram_id)], }, - # The fallbacks list defines handlers that are called if the user - # sends a message that doesn't match the current state's handler. - # Here, it's used to handle the /cancel command. + # Si algo falla o el usuario cancela fallbacks=[CommandHandler('cancel', cancel)], - # per_message=False means the conversation is tied to the user, not - # to a specific message, which is standard for this type of flow. per_message=False ) diff --git a/app/modules/equipo.py b/app/modules/equipo.py index ff020c2..7efbc10 100644 --- a/app/modules/equipo.py +++ b/app/modules/equipo.py @@ -1,45 +1,42 @@ # app/modules/equipo.py -""" -This module contains functionality for authorized team members. +# Este módulo contiene funciones para los miembros autorizados del equipo. +# Incluye un flujo para proponer actividades que el dueño debe aprobar. -It includes a conversational flow for proposing new activities that require -approval from the owner, as well as a function to check the status of -previously submitted requests. -""" from telegram import Update from telegram.ext import ContextTypes, ConversationHandler -# Define the states for the activity proposal conversation. +# Definimos los estados para la conversación de propuesta de actividad. DESCRIPTION, DURATION = range(2) async def propose_activity_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """ - Starts the conversation for a team member to propose a new activity. - This is typically triggered by an inline button press. + Inicia el proceso para que un miembro del equipo proponga una actividad. + Se activa cuando se pulsa el botón correspondiente. """ await update.callback_query.answer() await update.callback_query.edit_message_text( "Por favor, describe la actividad que quieres proponer." ) - # The function returns the next state, which is DESCRIPTION. + # Siguiente paso: DESCRIPTION return DESCRIPTION async def get_description(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """ - Stores the activity description provided by the user and asks for the duration. + Guarda la descripción de la actividad y pide la duración. """ context.user_data['activity_description'] = update.message.text await update.message.reply_text( "Entendido. Ahora, por favor, indica la duración estimada en horas (ej. 2, 4.5)." ) - # The function returns the next state, DURATION. + # Siguiente paso: DURATION return DURATION async def get_duration(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """ - Stores the activity duration, confirms the proposal to the user, and ends the conversation. + Guarda la duración, confirma la propuesta y termina la conversación. """ try: + # Intentamos convertir el texto a un número decimal (float) duration = float(update.message.text) context.user_data['activity_duration'] = duration description = context.user_data.get('activity_description', 'N/A') @@ -51,24 +48,22 @@ async def get_duration(update: Update, context: ContextTypes.DEFAULT_TYPE) -> in "Recibirás una notificación cuando sea revisada." ) - # TODO: Send this proposal to the owner for approval, for example, - # by sending a webhook or saving it to a database. + # TODO: Enviar esta propuesta al dueño (por webhook o base de datos). await update.message.reply_text(confirmation_text, parse_mode='Markdown') - # Clean up user_data to prevent data leakage into other conversations. + # Limpiamos los datos temporales context.user_data.clear() - # End the conversation. + # Terminamos la conversación return ConversationHandler.END except ValueError: - # If the user provides an invalid number for the duration, ask again. + # Si el usuario no escribe un número válido, se lo pedimos de nuevo await update.message.reply_text("Por favor, introduce un número válido para la duración en horas.") return DURATION async def cancel_proposal(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """ - Cancels and ends the activity proposal conversation. - This is triggered by the /cancel command. + Cancela el proceso de propuesta si el usuario escribe /cancel. """ await update.message.reply_text("La propuesta de actividad ha sido cancelada.") context.user_data.clear() @@ -76,10 +71,9 @@ async def cancel_proposal(update: Update, context: ContextTypes.DEFAULT_TYPE) -> def view_requests_status(): """ - Allows a team member to see the status of their recent requests. - - Currently, this returns a hardcoded sample status. In a real-world - application, this would fetch the user's requests from a database. + Permite a un miembro del equipo ver el estado de sus solicitudes recientes. + + Por ahora devuelve un estado de ejemplo fijo. """ - # TODO: Fetch the status of recent requests from a persistent data source. + # TODO: Obtener el estado real desde una base de datos. return "Aquí está el estado de tus solicitudes recientes:\n\n- Grabación de proyecto (4h): Aprobado\n- Taller de guion (2h): Pendiente" diff --git a/app/modules/onboarding.py b/app/modules/onboarding.py index c555fdb..cc946a8 100644 --- a/app/modules/onboarding.py +++ b/app/modules/onboarding.py @@ -1,16 +1,11 @@ # app/modules/onboarding.py -""" -This module handles the initial interaction with the user, specifically the -/start command. +# Este módulo maneja la primera interacción con el usuario (el comando /start). +# Se encarga de mostrar un menú diferente según quién sea el usuario (dueño, admin, equipo o cliente). -It is responsible for identifying the user's role and presenting them with a -customized menu of options based on their permissions. This ensures that each -user sees only the actions relevant to them. -""" from telegram import InlineKeyboardButton, InlineKeyboardMarkup def get_owner_menu(): - """Creates and returns the main menu keyboard for the 'owner' role.""" + """Crea el menú de botones para el Dueño (Owner).""" keyboard = [ [InlineKeyboardButton("📅 Ver mi agenda", callback_data='view_agenda')], [InlineKeyboardButton("⏳ Ver pendientes", callback_data='view_pending')], @@ -18,7 +13,7 @@ def get_owner_menu(): return InlineKeyboardMarkup(keyboard) def get_admin_menu(): - """Creates and returns the main menu keyboard for the 'admin' role.""" + """Crea el menú de botones para los Administradores.""" keyboard = [ [InlineKeyboardButton("📊 Ver estado del sistema", callback_data='view_system_status')], [InlineKeyboardButton("👥 Gestionar usuarios", callback_data='manage_users')], @@ -26,7 +21,7 @@ def get_admin_menu(): return InlineKeyboardMarkup(keyboard) def get_team_menu(): - """Creates and returns the main menu keyboard for the 'team' role.""" + """Crea el menú de botones para los Miembros del Equipo.""" keyboard = [ [InlineKeyboardButton("🕒 Proponer actividad", callback_data='propose_activity')], [InlineKeyboardButton("📄 Ver estatus de solicitudes", callback_data='view_requests_status')], @@ -34,7 +29,7 @@ def get_team_menu(): return InlineKeyboardMarkup(keyboard) def get_client_menu(): - """Creates and returns the main menu keyboard for the 'client' role.""" + """Crea el menú de botones para los Clientes externos.""" keyboard = [ [InlineKeyboardButton("🗓️ Agendar una cita", callback_data='schedule_appointment')], [InlineKeyboardButton("ℹ️ Información de servicios", callback_data='get_service_info')], @@ -43,20 +38,18 @@ def get_client_menu(): def handle_start(user_role): """ - Handles the /start command by sending a role-based welcome message and menu. - - This function acts as a router, determining which menu to display based on - the user's role, which is passed in as an argument. + Decide qué mensaje y qué menú mostrar según el rol del usuario. """ welcome_message = "Hola, soy Talía. ¿En qué puedo ayudarte hoy?" + # Dependiendo del rol, llamamos a una función de menú diferente if user_role == "owner": menu = get_owner_menu() elif user_role == "admin": menu = get_admin_menu() elif user_role == "team": menu = get_team_menu() - else: # Default to the client menu for any other role. + else: # Por defecto, si no es ninguno de los anteriores, es un cliente menu = get_client_menu() return welcome_message, menu diff --git a/app/modules/print.py b/app/modules/print.py index 4b85afd..3964258 100644 --- a/app/modules/print.py +++ b/app/modules/print.py @@ -1,11 +1,7 @@ # app/modules/print.py -""" -This module provides a command for administrators to print out the current -configuration details of the bot. +# Este módulo permite a los administradores imprimir los detalles de configuración del bot. +# Es una herramienta útil para depuración (debugging). -It is a debugging and administrative tool that allows authorized users to quickly -inspect key configuration variables without accessing the environment directly. -""" from telegram import Update from telegram.ext import ContextTypes from ..permissions import is_admin @@ -13,21 +9,22 @@ from ..config import TIMEZONE, CALENDAR_ID, N8N_WEBHOOK_URL async def print_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """ - Handles the /print command. - - When triggered, this function first checks if the user has admin privileges. - If they do, it replies with a formatted message displaying the current values - of the TIMEZONE, CALENDAR_ID, and N8N_WEBHOOK_URL configuration variables. - If the user is not an admin, it sends a simple "not authorized" message. + Maneja el comando /print. + + Verifica si el usuario es administrador. Si lo es, muestra valores clave + de la configuración (Zona horaria, ID de calendario, Webhook). """ chat_id = update.effective_chat.id + + # Solo permitimos esto a los administradores if is_admin(chat_id): config_details = ( - f"**Configuration Details**\n" - f"Timezone: `{TIMEZONE}`\n" - f"Calendar ID: `{CALENDAR_ID}`\n" - f"n8n Webhook URL: `{N8N_WEBHOOK_URL}`\n" + f"**Detalles de Configuración**\n" + f"Zona Horaria: `{TIMEZONE}`\n" + f"ID de Calendario: `{CALENDAR_ID}`\n" + f"URL Webhook n8n: `{N8N_WEBHOOK_URL}`\n" ) await update.message.reply_text(config_details, parse_mode='Markdown') else: - await update.message.reply_text("You are not authorized to use this command.") + # Si no es admin, le avisamos que no tiene permiso + await update.message.reply_text("No tienes autorización para usar este comando.") diff --git a/app/modules/servicios.py b/app/modules/servicios.py index 6d654da..a1a47d7 100644 --- a/app/modules/servicios.py +++ b/app/modules/servicios.py @@ -1,20 +1,13 @@ # app/modules/servicios.py -""" -This module is responsible for providing information about the services offered. - -It's a simple informational module that gives clients an overview of the -available services and can be expanded to provide more detailed information -or initiate a quoting process. -""" +# Este módulo se encarga de dar información sobre los servicios ofrecidos. +# Es un módulo informativo para los clientes. def get_service_info(): """ - Provides a brief overview of the available services. - - Currently, this function returns a hardcoded list of services. For a more - dynamic and easily maintainable system, this information could be fetched - from a database, a configuration file, or an external API. + Muestra una lista breve de los servicios disponibles. + + Por ahora devuelve un texto fijo. Se podría conectar a una base de datos + para que sea más fácil de actualizar. """ - # TODO: Fetch service details from a database or a configuration file to - # make the service list easier to manage and update. + # TODO: Obtener detalles de servicios desde una base de datos o archivo de configuración. return "Ofrecemos una variedad de servicios, incluyendo:\n\n- Consultoría Estratégica\n- Desarrollo de Software\n- Talleres de Capacitación\n\n¿Sobre cuál te gustaría saber más?" diff --git a/app/permissions.py b/app/permissions.py index 9af6c60..ce012bb 100644 --- a/app/permissions.py +++ b/app/permissions.py @@ -1,34 +1,39 @@ # app/permissions.py +# Este script maneja los permisos de los usuarios según su ID de chat de Telegram. from config import OWNER_CHAT_ID, ADMIN_CHAT_IDS, TEAM_CHAT_IDS def get_user_role(chat_id): """ - Determines the role of a user based on their chat ID. + Determina el rol de un usuario basado en su ID de chat. + + Roles posibles: owner (dueño), admin (administrador), team (equipo), client (cliente). """ chat_id_str = str(chat_id) + + # Si el ID coincide con el del dueño if chat_id_str == OWNER_CHAT_ID: return "owner" + + # Si el ID está en la lista de administradores if chat_id_str in ADMIN_CHAT_IDS: return "admin" + + # Si el ID está en la lista del equipo if chat_id_str in TEAM_CHAT_IDS: return "team" + + # Si no es ninguno de los anteriores, es un cliente normal return "client" def is_owner(chat_id): - """ - Checks if a user is the owner. - """ + """Verifica si un usuario es el dueño.""" return get_user_role(chat_id) == "owner" def is_admin(chat_id): - """ - Checks if a user is an admin. - """ + """Verifica si un usuario es administrador o dueño.""" return get_user_role(chat_id) in ["owner", "admin"] def is_team_member(chat_id): - """ - Checks if a user is a team member. - """ + """Verifica si un usuario es parte del equipo, administrador o dueño.""" return get_user_role(chat_id) in ["owner", "admin", "team"] diff --git a/app/scheduler.py b/app/scheduler.py index aef9406..8a2ce1d 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -1,4 +1,6 @@ # app/scheduler.py +# Este script se encarga de programar tareas automáticas, como el resumen diario. + import logging from datetime import time from telegram.ext import ContextTypes @@ -7,41 +9,51 @@ import pytz from config import OWNER_CHAT_ID, TIMEZONE from modules.agenda import get_agenda -# Enable logging +# Configuramos el registro de eventos (logging) para ver qué pasa en la consola logger = logging.getLogger(__name__) async def send_daily_summary(context: ContextTypes.DEFAULT_TYPE) -> None: - """Sends the daily summary to the owner.""" + """ + Función que envía el resumen diario al dueño del bot. + Se ejecuta automáticamente según lo programado. + """ job = context.job chat_id = job.chat_id - logger.info(f"Running daily summary job for chat_id: {chat_id}") + logger.info(f"Ejecutando tarea de resumen diario para el chat_id: {chat_id}") try: + # Obtenemos la agenda del día agenda_text = get_agenda() + # Preparamos el mensaje summary_text = f"🔔 *Resumen Diario - Buen día, Marco!*\n\n{agenda_text}" + # Enviamos el mensaje por Telegram await context.bot.send_message( chat_id=chat_id, text=summary_text, parse_mode='Markdown' ) - logger.info(f"Successfully sent daily summary to {chat_id}") + logger.info(f"Resumen diario enviado con éxito a {chat_id}") except Exception as e: - logger.error(f"Failed to send daily summary to {chat_id}: {e}") + # Si hay un error, lo registramos + logger.error(f"Error al enviar el resumen diario a {chat_id}: {e}") def schedule_daily_summary(application) -> None: - """Schedules the daily summary job.""" + """ + Programa la tarea del resumen diario para que ocurra todos los días. + """ + # Si no hay un ID de dueño configurado, no programamos nada if not OWNER_CHAT_ID: - logger.warning("OWNER_CHAT_ID not set. Daily summary will not be scheduled.") + logger.warning("OWNER_CHAT_ID no configurado. No se programará el resumen diario.") return job_queue = application.job_queue - # Use the timezone from config + # Configuramos la zona horaria (ej. America/Mexico_City) tz = pytz.timezone(TIMEZONE) - # Schedule the job to run every day at 7:00 AM + # Programamos la tarea para que corra todos los días a las 7:00 AM scheduled_time = time(hour=7, minute=0, tzinfo=tz) job_queue.run_daily( @@ -51,4 +63,4 @@ def schedule_daily_summary(application) -> None: name="daily_summary" ) - logger.info(f"Scheduled daily summary for {OWNER_CHAT_ID} at {scheduled_time} {TIMEZONE}") + logger.info(f"Resumen diario programado para {OWNER_CHAT_ID} a las {scheduled_time} ({TIMEZONE})") diff --git a/app/webhook_client.py b/app/webhook_client.py index e1af998..00062da 100644 --- a/app/webhook_client.py +++ b/app/webhook_client.py @@ -1,16 +1,25 @@ # app/webhook_client.py +# Este script se encarga de enviar datos a servicios externos usando "webhooks". +# En este caso, se comunica con n8n. import requests from config import N8N_WEBHOOK_URL def send_webhook(event_data): """ - Sends a webhook to the n8n service. + Envía datos de un evento al servicio n8n. + + Parámetros: + - event_data: Un diccionario con la información que queremos enviar. """ try: + # Hacemos una petición POST (enviar datos) a la URL configurada response = requests.post(N8N_WEBHOOK_URL, json=event_data) + # Verificamos si la petición fue exitosa (status code 200-299) response.raise_for_status() + # Devolvemos la respuesta del servidor en formato JSON return response.json() except requests.exceptions.RequestException as e: - print(f"Error sending webhook: {e}") + # Si hay un error en la conexión o el envío, lo mostramos + print(f"Error al enviar el webhook: {e}") return None