diff --git a/app/main.py b/app/main.py index b2faf86..594c2e9 100644 --- a/app/main.py +++ b/app/main.py @@ -15,8 +15,9 @@ from telegram.ext import ( # Importamos las configuraciones y herramientas que creamos en otros archivos from config import TELEGRAM_BOT_TOKEN -from permissions import get_user_role, is_admin +from permissions import get_user_role from modules.onboarding import handle_start as onboarding_handle_start +from modules.onboarding import get_admin_secondary_menu from modules.agenda import get_agenda from modules.citas import request_appointment from modules.equipo import ( @@ -33,7 +34,8 @@ from modules.servicios import get_service_info from modules.admin import get_system_status from modules.print import print_handler from modules.create_tag import create_tag_conv_handler, create_tag_start -from modules.vikunja import vikunja_conv_handler +from modules.vikunja import vikunja_conv_handler, get_tasks as get_vikunja_tasks + from scheduler import schedule_daily_summary # Configuramos el sistema de logs para ver mensajes de estado en la consola @@ -64,17 +66,14 @@ async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE) Dependiendo de qué botón se presione, ejecuta una acción diferente. """ query = update.callback_query - await query.answer() # Avisa a Telegram que recibimos el clic + await query.answer() logger.info(f"El despachador recibió una consulta: {query.data}") - # Texto por defecto si no encontramos la acción response_text = "Acción no reconocida." reply_markup = None - # Diccionario de acciones simples (que solo devuelven texto) simple_handlers = { 'view_agenda': get_agenda, - 'view_tasks': get_tasks, 'view_requests_status': view_requests_status, 'schedule_appointment': request_appointment, 'get_service_info': get_service_info, @@ -82,43 +81,38 @@ async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE) 'manage_users': lambda: "Función de gestión de usuarios no implementada.", } - # Diccionario de acciones complejas (que devuelven texto y botones) complex_handlers = { 'view_pending': view_pending, + 'admin_menu': get_admin_secondary_menu, } - # 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]() + await query.edit_message_text(text=response_text, parse_mode='Markdown') elif query.data in complex_handlers: response_text, reply_markup = complex_handlers[query.data]() + await query.edit_message_text(text=response_text, reply_markup=reply_markup, parse_mode='Markdown') elif query.data.startswith(('approve:', 'reject:')): - # Manejo especial para botones de aprobar o rechazar response_text = handle_approval_action(query.data) + await query.edit_message_text(text=response_text, parse_mode='Markdown') elif query.data == 'start_create_tag': - # Iniciamos el flujo de creación de tag await query.message.reply_text("Iniciando creación de tag...") - # Aquí simulamos el comando /create_tag return await create_tag_start(update, context) + else: + # Si no es ninguna de las acciones conocidas, asumimos que es para un ConversationHandler + # y no hacemos nada aquí para no interferir. + logger.warning(f"Consulta no manejada por el despachador principal: {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: """Función principal que arranca el bot.""" - # Verificamos que tengamos el token del bot if not TELEGRAM_BOT_TOKEN: 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() - - # Programamos el resumen diario schedule_daily_summary(application) - # 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={ @@ -129,7 +123,8 @@ def main() -> None: per_message=False ) - # Registramos todos los manejadores de eventos en la aplicación + # El order de los handlers importa. El dispatcher debe ir después de los ConversationHandlers + # para no interceptar sus callbacks. application.add_handler(conv_handler) application.add_handler(create_tag_conv_handler()) application.add_handler(vikunja_conv_handler()) @@ -137,10 +132,8 @@ def main() -> None: application.add_handler(CommandHandler("print", print_handler)) application.add_handler(CallbackQueryHandler(button_dispatcher)) - # 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/onboarding.py b/app/modules/onboarding.py index 380268d..4a05f2d 100644 --- a/app/modules/onboarding.py +++ b/app/modules/onboarding.py @@ -13,14 +13,26 @@ def get_owner_menu(): return InlineKeyboardMarkup(keyboard) def get_admin_menu(): - """Crea el menú de botones para los Administradores.""" + """Crea el menú de botones principal para los Administradores.""" keyboard = [ - [InlineKeyboardButton("📋 Ver Tareas (Vikunja)", callback_data='view_tasks')], - [InlineKeyboardButton("🏷️ Crear Tag NFC", callback_data='start_create_tag')], - [InlineKeyboardButton("📊 Estado del sistema", callback_data='view_system_status')], + [InlineKeyboardButton("⏳ Revisar Pendientes", callback_data='view_pending')], + [InlineKeyboardButton("📅 Agenda", callback_data='view_agenda')], + [InlineKeyboardButton("🏷️ Crear Tag", callback_data='start_create_tag')], + [InlineKeyboardButton("▶️ Más opciones", callback_data='admin_menu')], ] return InlineKeyboardMarkup(keyboard) +def get_admin_secondary_menu(): + """Crea el menú secundario para Administradores.""" + text = "Aquí tienes más opciones de administración:" + keyboard = [ + [InlineKeyboardButton("📋 Gestionar Tareas (Vikunja)", callback_data='manage_vikunja')], + [InlineKeyboardButton("📊 Estado del sistema", callback_data='view_system_status')], + [InlineKeyboardButton("👥 Gestionar Usuarios", callback_data='manage_users')], + ] + reply_markup = InlineKeyboardMarkup(keyboard) + return text, reply_markup + def get_team_menu(): """Crea el menú de botones para los Miembros del Equipo.""" keyboard = [ @@ -43,14 +55,13 @@ def handle_start(user_role): """ 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: # Por defecto, si no es ninguno de los anteriores, es un cliente + else: menu = get_client_menu() return welcome_message, menu diff --git a/app/modules/vikunja.py b/app/modules/vikunja.py index 0729d4e..b97651d 100644 --- a/app/modules/vikunja.py +++ b/app/modules/vikunja.py @@ -19,8 +19,8 @@ from app.permissions import is_admin # Configuración del logger logger = logging.getLogger(__name__) -# Definición de los estados de la conversación -SELECTING_ACTION, ADDING_TASK = range(2) +# Definición de los estados de la conversación para añadir y editar tareas +SELECTING_ACTION, ADDING_TASK, SELECTING_TASK_TO_EDIT, EDITING_TASK = range(4) def get_vikunja_headers(): """Devuelve los headers necesarios para la API de Vikunja.""" @@ -29,29 +29,13 @@ def get_vikunja_headers(): "Content-Type": "application/json", } -async def vik_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - """Inicia la conversación de Vikunja y muestra el menú de acciones.""" - if not is_admin(update.effective_chat.id): - await update.message.reply_text("No tienes permiso para usar este comando.") - return ConversationHandler.END - - keyboard = [ - [InlineKeyboardButton("Ver Tareas", callback_data='view_tasks')], - [InlineKeyboardButton("Añadir Tarea", callback_data='add_task')], - [InlineKeyboardButton("Cancelar", callback_data='cancel')], - ] - reply_markup = InlineKeyboardMarkup(keyboard) - await update.message.reply_text("Selecciona una opción para Vikunja:", reply_markup=reply_markup) - return SELECTING_ACTION - -async def view_tasks(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - """Muestra la lista de tareas de Vikunja.""" - query = update.callback_query - await query.answer() - +def get_tasks(): + """ + Obtiene y formatea la lista de tareas de Vikunja. + Esta función es síncrona y devuelve un string. + """ if not VIKUNJA_API_TOKEN: - await query.edit_message_text("Error: VIKUNJA_API_TOKEN no configurado.") - return ConversationHandler.END + return "Error: VIKUNJA_API_TOKEN no configurado." try: response = requests.get(f"{VIKUNJA_API_URL}/projects/1/tasks", headers=get_vikunja_headers()) @@ -59,19 +43,32 @@ async def view_tasks(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: tasks = response.json() if not tasks: - text = "No tienes tareas pendientes en Vikunja." - else: - text = "📋 *Tus Tareas en Vikunja*\n\n" - for task in tasks[:10]: - status = "✅" if task.get('done') else "⏳" - text += f"{status} *{task.get('title')}*\n" + return "No tienes tareas pendientes en Vikunja." - await query.edit_message_text(text, parse_mode='Markdown') + text = "📋 *Tus Tareas en Vikunja*\n\n" + for task in sorted(tasks, key=lambda t: t.get('id', 0))[:10]: + status = "✅" if task.get('done') else "⏳" + text += f"{status} `{task.get('id')}`: *{task.get('title')}*\n" + return text except Exception as e: logger.error(f"Error al obtener tareas de Vikunja: {e}") - await query.edit_message_text(f"Error al conectar con Vikunja: {e}") + return f"Error al conectar con Vikunja: {e}" - return ConversationHandler.END +async def vikunja_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Muestra el menú principal de acciones de Vikunja.""" + query = update.callback_query + await query.answer() + + keyboard = [ + [InlineKeyboardButton("Añadir Tarea", callback_data='add_task')], + [InlineKeyboardButton("Editar Tarea", callback_data='edit_task_start')], + [InlineKeyboardButton("Volver", callback_data='cancel')], + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + tasks_list = get_tasks() + await query.edit_message_text(text=f"{tasks_list}\n\nSelecciona una acción:", reply_markup=reply_markup, parse_mode='Markdown') + return SELECTING_ACTION async def request_task_title(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Solicita al usuario el título de la nueva tarea.""" @@ -83,13 +80,7 @@ async def request_task_title(update: Update, context: ContextTypes.DEFAULT_TYPE) async def add_task(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Añade una nueva tarea a Vikunja.""" task_title = update.message.text - - if not VIKUNJA_API_TOKEN: - await update.message.reply_text("Error: VIKUNJA_API_TOKEN no configurado.") - return ConversationHandler.END - try: - # Usamos un project_id=1 hardcodeado como se definió en el plan data = {"title": task_title, "project_id": 1} response = requests.post(f"{VIKUNJA_API_URL}/tasks", headers=get_vikunja_headers(), json=data) response.raise_for_status() @@ -100,8 +91,71 @@ async def add_task(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: return ConversationHandler.END +async def select_task_to_edit(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Muestra los botones para seleccionar qué tarea editar.""" + query = update.callback_query + await query.answer() + + try: + response = requests.get(f"{VIKUNJA_API_URL}/projects/1/tasks", headers=get_vikunja_headers()) + response.raise_for_status() + tasks = [task for task in response.json() if not task.get('done')] + + if not tasks: + await query.edit_message_text("No hay tareas pendientes para editar.") + return ConversationHandler.END + + keyboard = [] + for task in sorted(tasks, key=lambda t: t.get('id', 0))[:10]: + keyboard.append([InlineKeyboardButton( + f"{task.get('id')}: {task.get('title')}", + callback_data=f"edit_task:{task.get('id')}" + )]) + keyboard.append([InlineKeyboardButton("Cancelar", callback_data='cancel')]) + + reply_markup = InlineKeyboardMarkup(keyboard) + await query.edit_message_text("Selecciona la tarea que quieres editar:", reply_markup=reply_markup) + return SELECTING_TASK_TO_EDIT + except Exception as e: + logger.error(f"Error al obtener tareas para editar: {e}") + await query.edit_message_text("Error al obtener la lista de tareas.") + return ConversationHandler.END + +async def request_new_task_title(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Solicita el nuevo título para la tarea seleccionada.""" + query = update.callback_query + await query.answer() + + task_id = query.data.split(':')[1] + context.user_data['task_id_to_edit'] = task_id + + await query.edit_message_text(f"Introduce el nuevo título para la tarea `{task_id}`:", parse_mode='Markdown') + return EDITING_TASK + +async def edit_task(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Actualiza el título de una tarea en Vikunja.""" + new_title = update.message.text + task_id = context.user_data.get('task_id_to_edit') + + if not task_id: + await update.message.reply_text("Error: No se encontró el ID de la tarea a editar.") + return ConversationHandler.END + + try: + data = {"title": new_title} + response = requests.put(f"{VIKUNJA_API_URL}/tasks/{task_id}", headers=get_vikunja_headers(), json=data) + response.raise_for_status() + await update.message.reply_text(f"✅ Tarea `{task_id}` actualizada a *{new_title}*", parse_mode='Markdown') + except Exception as e: + logger.error(f"Error al editar la tarea {task_id}: {e}") + await update.message.reply_text("Error al actualizar la tarea.") + finally: + del context.user_data['task_id_to_edit'] + + return ConversationHandler.END + async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - """Cancela la conversación.""" + """Cancela la conversación actual.""" query = update.callback_query await query.answer() await query.edit_message_text("Operación cancelada.") @@ -110,16 +164,19 @@ async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: def vikunja_conv_handler(): """Crea el ConversationHandler para el flujo de Vikunja.""" return ConversationHandler( - entry_points=[CommandHandler('vik', vik_start)], + entry_points=[CallbackQueryHandler(vikunja_menu, pattern='^manage_vikunja$')], states={ SELECTING_ACTION: [ - CallbackQueryHandler(view_tasks, pattern='^view_tasks$'), CallbackQueryHandler(request_task_title, pattern='^add_task$'), + CallbackQueryHandler(select_task_to_edit, pattern='^edit_task_start$'), CallbackQueryHandler(cancel, pattern='^cancel$'), ], - ADDING_TASK: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, add_task) + ADDING_TASK: [MessageHandler(filters.TEXT & ~filters.COMMAND, add_task)], + SELECTING_TASK_TO_EDIT: [ + CallbackQueryHandler(request_new_task_title, pattern=r'^edit_task:\d+$'), + CallbackQueryHandler(cancel, pattern='^cancel$'), ], + EDITING_TASK: [MessageHandler(filters.TEXT & ~filters.COMMAND, edit_task)], }, fallbacks=[CommandHandler('cancel', cancel)], )