diff --git a/Dockerfile b/Dockerfile index 2ec0ad4..2fde24a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Python base image -FROM python:3.9-slim +FROM python:3.11-slim # Set working directory WORKDIR /talia_bot diff --git a/Tasks.md b/Tasks.md index dfeb602..0bf595e 100644 --- a/Tasks.md +++ b/Tasks.md @@ -13,6 +13,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: ASAP ### [SEC-002] Hardcoded Secrets Management +- **Status**: TODO - **Priority**: High - **Description**: Email credentials stored in plain text environment variables - **Files affected**: `config.py`, `.env.example` @@ -20,6 +21,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: Next sprint ### [SEC-003] SQL Injection Prevention +- **Status**: TODO - **Priority**: Medium - **Description**: Database connection lacks connection pooling and timeout configurations - **Files affected**: `db.py` @@ -37,6 +39,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: Next sprint ### [IMP-002] Dynamic Menu Generation +- **Status**: TODO - **Priority**: Medium - **Description**: `onboarding.py` has hardcoded menus instead of dynamic generation - **Files affected**: `onboarding.py` @@ -44,6 +47,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: Future iteration ### [IMP-003] Button Dispatcher Agent +- **Status**: TODO - **Priority**: Low - **Description**: "Despachador de Botones" mentioned in AGENTS.md but not implemented - **Files affected**: Need to create new module @@ -53,6 +57,7 @@ This document tracks all pending tasks, improvements, and issues identified in t ## **Architecture & Code Quality** 🟠 ### [ARCH-001] Main.py Business Logic Violation +- **Status**: TODO - **Priority**: Medium - **Description**: `main.py` contains business logic (lines 56-95) violating "Recepcionista" agent role - **Files affected**: `main.py` @@ -60,6 +65,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: Next sprint ### [ARCH-002] Error Handling Consistency +- **Status**: TODO - **Priority**: Medium - **Description**: Inconsistent error handling across modules, missing try-catch blocks - **Files affected**: `flow_engine.py`, `printer.py`, multiple modules @@ -67,6 +73,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: Next sprint ### [ARCH-003] Code Duplication +- **Status**: TODO - **Priority**: Low - **Description**: Database connection patterns repeated, similar menu generation logic - **Files affected**: Multiple files @@ -76,6 +83,7 @@ This document tracks all pending tasks, improvements, and issues identified in t ## **Performance & Optimization** 🟢 ### [PERF-001] Database Connection Pooling +- **Status**: TODO - **Priority**: Medium - **Description**: No connection pooling, missing indexes on frequently queried columns - **Files affected**: `db.py` @@ -83,6 +91,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: Next sprint ### [PERF-002] Memory Management +- **Status**: TODO - **Priority**: Medium - **Description**: Voice files loaded entirely into memory, no cleanup for failed uploads - **Files affected**: `llm_engine.py`, `main.py` @@ -90,6 +99,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: Next sprint ### [PERF-003] Flow Engine Memory Usage +- **Status**: TODO - **Priority**: Low - **Description**: Flow engine stores all conversation data in memory - **Files affected**: `flow_engine.py` @@ -99,6 +109,7 @@ This document tracks all pending tasks, improvements, and issues identified in t ## **Dependencies & Configuration** 🔵 ### [DEP-001] Python Version Upgrade +- **Status**: TODO - **Priority**: High - **Description**: Using Python 3.9 (EOL October 2025) - should upgrade to 3.11+ - **Files affected**: `Dockerfile`, `requirements.txt` @@ -114,6 +125,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: ASAP ### [DEP-003] Docker Security Hardening +- **Status**: TODO - **Priority**: Medium - **Description**: Running as root user, missing security hardening - **Files affected**: `Dockerfile`, `docker-compose.yml` @@ -123,6 +135,7 @@ This document tracks all pending tasks, improvements, and issues identified in t ## **Bugs & Logical Errors** 🐛 ### [BUG-001] Flow Engine Validation +- **Status**: TODO - **Priority**: Medium - **Description**: `flow_engine.py:72` missing validation for empty steps array - **Files affected**: `flow_engine.py` @@ -130,6 +143,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: Next sprint ### [BUG-002] Printer Module IMAP Search +- **Status**: TODO - **Priority**: Medium - **Description**: `printer.py:88` IMAP search doesn't handle large email counts - **Files affected**: `printer.py` @@ -137,6 +151,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: Next sprint ### [BUG-003] Identity Module String Comparison +- **Status**: TODO - **Priority**: Low - **Description**: `identity.py:42` string comparison for ADMIN_ID could fail if numeric - **Files affected**: `identity.py` @@ -146,6 +161,7 @@ This document tracks all pending tasks, improvements, and issues identified in t ## **Documentation & Testing** 📚 ### [DOC-001] Documentation Consistency +- **Status**: TODO - **Priority**: Low - **Description**: AGENTS.md vs implementation inconsistencies - **Files affected**: `AGENTS.md`, various modules @@ -153,6 +169,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: Future iteration ### [TEST-001] Test Coverage +- **Status**: TODO - **Priority**: Low - **Description**: Missing comprehensive test coverage - **Files affected**: All modules @@ -160,6 +177,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Due**: Future iteration ### [TEST-002] Code Quality Tools +- **Status**: TODO - **Priority**: Low - **Description**: Missing code quality tools (black, flake8, mypy) - **Files affected**: Repository configuration @@ -170,12 +188,12 @@ This document tracks all pending tasks, improvements, and issues identified in t ## **Sprint Planning** -### **Current Sprint (High Priority)** +### **Previous Sprint (High Priority)** - **[DONE]** [SEC-001] File upload security validation - **[DONE]** [DEP-002] Package security updates - **[DONE]** [IMP-001] Whisper transcription agent -### **Next Sprint (Medium Priority)** +### **Current Sprint (Medium Priority)** - [SEC-002] Secret management implementation - [SEC-003] Database connection pooling - [DEP-001] Python version upgrade diff --git a/bot/db.py b/bot/db.py index 4762a4e..016c707 100644 --- a/bot/db.py +++ b/bot/db.py @@ -4,16 +4,27 @@ import sqlite3 import logging import os +import threading DATABASE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "users.db") logger = logging.getLogger(__name__) +# Use a thread-local object to manage the database connection +local = threading.local() + def get_db_connection(): """Creates a connection to the SQLite database.""" - conn = sqlite3.connect(DATABASE_FILE) - conn.row_factory = sqlite3.Row - return conn + if not hasattr(local, "conn"): + local.conn = sqlite3.connect(DATABASE_FILE, check_same_thread=False) + local.conn.row_factory = sqlite3.Row + return local.conn + +def close_db_connection(): + """Closes the database connection.""" + if hasattr(local, "conn"): + local.conn.close() + del local.conn def setup_database(): """Sets up the database tables if they don't exist.""" @@ -50,7 +61,7 @@ def setup_database(): logger.error(f"Database error during setup: {e}") finally: if conn: - conn.close() + close_db_connection() if __name__ == '__main__': # This allows us to run the script directly to initialize the database diff --git a/bot/main.py b/bot/main.py index 0304ce4..2233588 100644 --- a/bot/main.py +++ b/bot/main.py @@ -2,19 +2,16 @@ # Este es el archivo principal del bot. Aquí se inicia todo y se configuran los comandos. import logging -import asyncio import sys from pathlib import Path -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram import Update from telegram.ext import ( Application, CommandHandler, CallbackQueryHandler, - ConversationHandler, MessageHandler, ContextTypes, filters, - TypeHandler, ) # Ensure package imports work even if the file is executed directly @@ -28,23 +25,11 @@ if __package__ is None: from bot.config import TELEGRAM_BOT_TOKEN from bot.modules.identity import get_user_role from bot.modules.onboarding import handle_start as onboarding_handle_start -from bot.modules.onboarding import get_admin_secondary_menu -from bot.modules.agenda import get_agenda -from bot.modules.citas import request_appointment -from bot.modules.equipo import ( - view_requests_status, -) -from bot.modules.aprobaciones import view_pending, handle_approval_action -from bot.modules.admin import get_system_status -import os -from bot.modules.debug import print_handler -from bot.modules.vikunja import vikunja_conv_handler, get_projects_list, get_tasks_list -from bot.modules.printer import send_file_to_printer, check_print_status -from bot.db import setup_database +from bot.modules.printer import handle_document, check_print_status +from bot.db import setup_database, close_db_connection from bot.modules.flow_engine import FlowEngine -from bot.modules.transcription import transcribe_audio -from bot.modules.file_validation import validate_document - +from bot.modules.dispatcher import button_dispatcher +from bot.modules.message_handler import text_and_voice_handler from bot.scheduler import schedule_daily_summary # Configuramos el sistema de logs para ver mensajes de estado en la consola @@ -53,47 +38,6 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) - -async def send_step_message(update: Update, step: dict): - """Helper to send a message for a flow step, including options if available.""" - text = step["question"] - reply_markup = None - - options = [] - if "options" in step and step["options"]: - options = step["options"] - elif "input_type" in step: - if step["input_type"] == "dynamic_keyboard_vikunja_projects": - projects = get_projects_list() - # Assuming project has 'title' or 'id' - options = [p.get('title', 'Unknown') for p in projects] - elif step["input_type"] == "dynamic_keyboard_vikunja_tasks": - # NOTE: We ideally need the project_id selected in previous step. - # For now, defaulting to project 1 or generic fetch - tasks = get_tasks_list(1) - options = [t.get('title', 'Unknown') for t in tasks] - - if options: - keyboard = [] - # Create a row for each option or group them - row = [] - for option in options: - # Check if option is simple string or object (not implemented in JSONs seen so far) - # Ensure callback_data is not too long - cb_data = str(option)[:64] - row.append(InlineKeyboardButton(str(option), callback_data=cb_data)) - if len(row) >= 2: - keyboard.append(row) - row = [] - if row: - keyboard.append(row) - reply_markup = InlineKeyboardMarkup(keyboard) - - if update.callback_query: - await update.callback_query.edit_message_text(text=text, reply_markup=reply_markup) - else: - await update.message.reply_text(text=text, reply_markup=reply_markup) - async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """ Se ejecuta cuando el usuario escribe /start. @@ -108,7 +52,6 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: logger.info(f"User {chat_id} started a new conversation, clearing any previous state.") user_role = get_user_role(chat_id) - 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 @@ -118,81 +61,6 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text(response_text, reply_markup=reply_markup) -async def text_and_voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handles text and voice messages for the flow engine.""" - user_id = update.effective_user.id - flow_engine = context.bot_data["flow_engine"] - - state = flow_engine.get_conversation_state(user_id) - if not state: - # If there's no active conversation, treat it as a start command - # await start(update, context) # Changed behavior: Don't auto-start, might be annoying - return - - user_response = update.message.text - if update.message.voice: - voice = update.message.voice - temp_dir = 'temp_files' - os.makedirs(temp_dir, exist_ok=True) - file_path = os.path.join(temp_dir, f"{voice.file_id}.ogg") - - try: - voice_file = await context.bot.get_file(voice.file_id) - await voice_file.download_to_drive(file_path) - logger.info(f"Voice message saved to {file_path}") - - user_response = transcribe_audio(file_path) - logger.info(f"Transcription result: '{user_response}'") - - except Exception as e: - logger.error(f"Error during voice transcription: {e}") - user_response = "Error al procesar el mensaje de voz." - finally: - if os.path.exists(file_path): - os.remove(file_path) - - result = flow_engine.handle_response(user_id, user_response) - - if result["status"] == "in_progress": - await send_step_message(update, result["step"]) - elif result["status"] == "complete": - if "sales_pitch" in result: - await update.message.reply_text(result["sales_pitch"]) - elif "nfc_tag" in result: - await update.message.reply_text(result["nfc_tag"], parse_mode='Markdown') - else: - await update.message.reply_text("Gracias por completar el flujo.") - elif result["status"] == "error": - await update.message.reply_text(result["message"]) - - -async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handles documents sent to the bot for printing.""" - document = update.message.document - user_id = update.effective_user.id - - # Validate the document before processing - is_valid, message = validate_document(document) - if not is_valid: - await update.message.reply_text(message) - return - - file = await context.bot.get_file(document.file_id) - - # Create a directory for temporary files if it doesn't exist - temp_dir = 'temp_files' - os.makedirs(temp_dir, exist_ok=True) - file_path = os.path.join(temp_dir, document.file_name) - - await file.download_to_drive(file_path) - - response = await send_file_to_printer(file_path, user_id, document.file_name) - await update.message.reply_text(response) - - # Clean up the downloaded file - os.remove(file_path) - - async def check_print_status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Command to check print status.""" user_id = update.effective_user.id @@ -209,97 +77,6 @@ async def reset_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) logger.info(f"User {user_id} reset their conversation.") -async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """ - 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"El despachador recibió una consulta: {query.data}") - - response_text = "Acción no reconocida." - reply_markup = None - - simple_handlers = { - 'view_agenda': get_agenda, - 'view_requests_status': view_requests_status, - 'schedule_appointment': request_appointment, - 'view_system_status': get_system_status, - 'manage_users': lambda: "Función de gestión de usuarios no implementada.", - } - - complex_handlers = { - 'admin_menu': get_admin_secondary_menu, - 'view_pending': view_pending, - } - - try: - if query.data in simple_handlers: - handler = simple_handlers[query.data] - logger.info(f"Ejecutando simple_handler para: {query.data}") - if asyncio.iscoroutinefunction(handler): - response_text = await handler() - else: - response_text = handler() - elif query.data in complex_handlers: - handler = complex_handlers[query.data] - logger.info(f"Ejecutando complex_handler para: {query.data}") - if asyncio.iscoroutinefunction(handler): - response_text, reply_markup = await handler() - else: - response_text, reply_markup = handler() - elif query.data.startswith(('approve:', 'reject:')): - logger.info(f"Ejecutando acción de aprobación: {query.data}") - response_text = handle_approval_action(query.data) - else: - # Check if the button is a flow trigger - flow_engine = context.bot_data["flow_engine"] - flow_to_start = next((flow for flow in flow_engine.flows if flow.get("trigger_button") == query.data), None) - - if flow_to_start: - logger.info(f"Iniciando flujo: {flow_to_start['id']}") - initial_step = flow_engine.start_flow(update.effective_user.id, flow_to_start["id"]) - if initial_step: - await send_step_message(update, initial_step) - else: - logger.error("No se pudo iniciar el flujo (paso inicial vacío).") - return - - # Check if the user is in a flow and clicked an option - state = flow_engine.get_conversation_state(update.effective_user.id) - if state: - logger.info(f"Procesando paso de flujo para usuario {update.effective_user.id}. Data: {query.data}") - result = flow_engine.handle_response(update.effective_user.id, query.data) - - if result["status"] == "in_progress": - logger.info("Flujo en progreso, enviando siguiente paso.") - await send_step_message(update, result["step"]) - elif result["status"] == "complete": - logger.info("Flujo completado.") - if "sales_pitch" in result: - await query.edit_message_text(result["sales_pitch"]) - elif "nfc_tag" in result: - await query.edit_message_text(result["nfc_tag"], parse_mode='Markdown') - else: - await query.edit_message_text("Gracias por completar el flujo.") - elif result["status"] == "error": - logger.error(f"Error en el flujo: {result['message']}") - await query.edit_message_text(f"Error: {result['message']}") - return - - logger.warning(f"Consulta no manejada por el despachador: {query.data}") - # Only update text if no flow was started - await query.edit_message_text(text=response_text) - return - - except Exception as exc: - logger.exception(f"Error al procesar la acción {query.data}: {exc}") - response_text = "❌ Ocurrió un error al procesar tu solicitud. Intenta de nuevo." - reply_markup = None - - 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.""" if not TELEGRAM_BOT_TOKEN: @@ -310,25 +87,24 @@ def main() -> None: application = Application.builder().token(TELEGRAM_BOT_TOKEN).build() - # Instantiate and store the flow engine in bot_data flow_engine = FlowEngine() application.bot_data["flow_engine"] = flow_engine schedule_daily_summary(application) application.add_handler(CommandHandler("start", start)) - application.add_handler(CommandHandler("reset", reset_conversation)) # Added reset command - application.add_handler(CommandHandler("print", print_handler)) + application.add_handler(CommandHandler("reset", reset_conversation)) application.add_handler(CommandHandler("check_print_status", check_print_status_command)) - application.add_handler(MessageHandler(filters.Document.ALL, handle_document)) - application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND | filters.VOICE, text_and_voice_handler)) - application.add_handler(CallbackQueryHandler(button_dispatcher)) logger.info("Iniciando Talía Bot...") - application.run_polling() + try: + application.run_polling() + finally: + close_db_connection() + if __name__ == "__main__": main() diff --git a/bot/modules/dispatcher.py b/bot/modules/dispatcher.py new file mode 100644 index 0000000..4e7ab93 --- /dev/null +++ b/bot/modules/dispatcher.py @@ -0,0 +1,138 @@ +# bot/modules/dispatcher.py +# This module is responsible for dispatching user interactions to the correct handlers. + +import logging +import asyncio +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ContextTypes + +from bot.modules.onboarding import get_admin_secondary_menu +from bot.modules.agenda import get_agenda +from bot.modules.citas import request_appointment +from bot.modules.equipo import view_requests_status +from bot.modules.aprobaciones import view_pending, handle_approval_action +from bot.modules.admin import get_system_status +from bot.modules.vikunja import get_projects_list, get_tasks_list + +logger = logging.getLogger(__name__) + +async def send_step_message(update: Update, step: dict): + """Helper to send a message for a flow step, including options if available.""" + text = step["question"] + reply_markup = None + + options = [] + if "options" in step and step["options"]: + options = step["options"] + elif "input_type" in step: + if step["input_type"] == "dynamic_keyboard_vikunja_projects": + projects = get_projects_list() + options = [p.get('title', 'Unknown') for p in projects] + elif step["input_type"] == "dynamic_keyboard_vikunja_tasks": + tasks = get_tasks_list(1) + options = [t.get('title', 'Unknown') for t in tasks] + + if options: + keyboard = [] + row = [] + for option in options: + cb_data = str(option)[:64] + row.append(InlineKeyboardButton(str(option), callback_data=cb_data)) + if len(row) >= 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + reply_markup = InlineKeyboardMarkup(keyboard) + + if update.callback_query: + await update.callback_query.edit_message_text(text=text, reply_markup=reply_markup) + else: + await update.message.reply_text(text=text, reply_markup=reply_markup) + +async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """ + Handles button clicks and dispatches them to the appropriate handlers. + """ + query = update.callback_query + await query.answer() + logger.info(f"El despachador recibió una consulta: {query.data}") + + response_text = "Acción no reconocida." + reply_markup = None + + simple_handlers = { + 'view_agenda': get_agenda, + 'view_requests_status': view_requests_status, + 'schedule_appointment': request_appointment, + 'view_system_status': get_system_status, + 'manage_users': lambda: "Función de gestión de usuarios no implementada.", + } + + complex_handlers = { + 'admin_menu': get_admin_secondary_menu, + 'view_pending': view_pending, + } + + try: + if query.data in simple_handlers: + handler = simple_handlers[query.data] + logger.info(f"Ejecutando simple_handler para: {query.data}") + if asyncio.iscoroutinefunction(handler): + response_text = await handler() + else: + response_text = handler() + elif query.data in complex_handlers: + handler = complex_handlers[query.data] + logger.info(f"Ejecutando complex_handler para: {query.data}") + if asyncio.iscoroutinefunction(handler): + response_text, reply_markup = await handler() + else: + response_text, reply_markup = handler() + elif query.data.startswith(('approve:', 'reject:')): + logger.info(f"Ejecutando acción de aprobación: {query.data}") + response_text = handle_approval_action(query.data) + else: + flow_engine = context.bot_data["flow_engine"] + flow_to_start = next((flow for flow in flow_engine.flows if flow.get("trigger_button") == query.data), None) + + if flow_to_start: + logger.info(f"Iniciando flujo: {flow_to_start['id']}") + initial_step = flow_engine.start_flow(update.effective_user.id, flow_to_start["id"]) + if initial_step: + await send_step_message(update, initial_step) + else: + logger.error("No se pudo iniciar el flujo (paso inicial vacío).") + return + + state = flow_engine.get_conversation_state(update.effective_user.id) + if state: + logger.info(f"Procesando paso de flujo para usuario {update.effective_user.id}. Data: {query.data}") + result = flow_engine.handle_response(update.effective_user.id, query.data) + + if result["status"] == "in_progress": + logger.info("Flujo en progreso, enviando siguiente paso.") + await send_step_message(update, result["step"]) + elif result["status"] == "complete": + logger.info("Flujo completado.") + if "sales_pitch" in result: + await query.edit_message_text(result["sales_pitch"]) + elif "nfc_tag" in result: + await query.edit_message_text(result["nfc_tag"], parse_mode='Markdown') + else: + await query.edit_message_text("Gracias por completar el flujo.") + elif result["status"] == "error": + logger.error(f"Error en el flujo: {result['message']}") + await query.edit_message_text(f"Error: {result['message']}") + return + + logger.warning(f"Consulta no manejada por el despachador: {query.data}") + await query.edit_message_text(text=response_text) + return + + except Exception as exc: + logger.exception(f"Error al procesar la acción {query.data}: {exc}") + response_text = "❌ Ocurrió un error al procesar tu solicitud. Intenta de nuevo." + reply_markup = None + + await query.edit_message_text(text=response_text, reply_markup=reply_markup, parse_mode='Markdown') diff --git a/bot/modules/flow_engine.py b/bot/modules/flow_engine.py index e8f62fe..f8d0cf6 100644 --- a/bot/modules/flow_engine.py +++ b/bot/modules/flow_engine.py @@ -52,24 +52,27 @@ class FlowEngine: def get_conversation_state(self, user_id): """Gets the current conversation state for a user from the database.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT flow_id, current_step_id, collected_data FROM conversations WHERE user_id = ?", (user_id,)) - state = cursor.fetchone() - conn.close() - if state: - return { - "flow_id": state['flow_id'], - "current_step_id": state['current_step_id'], - "collected_data": json.loads(state['collected_data']) if state['collected_data'] else {} - } - return None + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT flow_id, current_step_id, collected_data FROM conversations WHERE user_id = ?", (user_id,)) + state = cursor.fetchone() + if state: + return { + "flow_id": state['flow_id'], + "current_step_id": state['current_step_id'], + "collected_data": json.loads(state['collected_data']) if state['collected_data'] else {} + } + return None + except sqlite3.Error as e: + logger.error(f"Database error in get_conversation_state: {e}") + return None def start_flow(self, user_id, flow_id): """Starts a new flow for a user.""" flow = self.get_flow(flow_id) - if not flow or 'steps' not in flow or not flow['steps']: - logger.error(f"Flow '{flow_id}' is invalid or has no steps.") + if not flow or 'steps' not in flow or not isinstance(flow['steps'], list) or not flow['steps']: + logger.error(f"Flow '{flow_id}' is invalid, has no steps, or steps is not a non-empty list.") return None initial_step = flow['steps'][0] @@ -78,14 +81,16 @@ class FlowEngine: def update_conversation_state(self, user_id, flow_id, step_id, collected_data): """Creates or updates the conversation state in the database.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - INSERT OR REPLACE INTO conversations (user_id, flow_id, current_step_id, collected_data) - VALUES (?, ?, ?, ?) - """, (user_id, flow_id, step_id, json.dumps(collected_data))) - conn.commit() - conn.close() + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO conversations (user_id, flow_id, current_step_id, collected_data) + VALUES (?, ?, ?, ?) + """, (user_id, flow_id, step_id, json.dumps(collected_data))) + conn.commit() + except sqlite3.Error as e: + logger.error(f"Database error in update_conversation_state: {e}") def handle_response(self, user_id, response_data): """ @@ -147,8 +152,10 @@ class FlowEngine: def end_flow(self, user_id): """Ends a flow for a user by deleting their conversation state.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("DELETE FROM conversations WHERE user_id = ?", (user_id,)) - conn.commit() - conn.close() + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM conversations WHERE user_id = ?", (user_id,)) + conn.commit() + except sqlite3.Error as e: + logger.error(f"Database error in end_flow: {e}") diff --git a/bot/modules/message_handler.py b/bot/modules/message_handler.py new file mode 100644 index 0000000..6d976f7 --- /dev/null +++ b/bot/modules/message_handler.py @@ -0,0 +1,57 @@ +# bot/modules/message_handler.py +# This module handles the processing of text and voice messages. + +import logging +import os +from telegram import Update +from telegram.ext import ContextTypes + +from bot.modules.transcription import transcribe_audio +from bot.modules.dispatcher import send_step_message + +logger = logging.getLogger(__name__) + +async def text_and_voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handles text and voice messages for the flow engine.""" + user_id = update.effective_user.id + flow_engine = context.bot_data["flow_engine"] + + state = flow_engine.get_conversation_state(user_id) + if not state: + return + + user_response = update.message.text + if update.message.voice: + voice = update.message.voice + temp_dir = 'temp_files' + os.makedirs(temp_dir, exist_ok=True) + file_path = os.path.join(temp_dir, f"{voice.file_id}.ogg") + + try: + voice_file = await context.bot.get_file(voice.file_id) + await voice_file.download_to_drive(file_path) + logger.info(f"Voice message saved to {file_path}") + + user_response = transcribe_audio(file_path) + logger.info(f"Transcription result: '{user_response}'") + + except Exception as e: + logger.error(f"Error during voice transcription: {e}") + user_response = "Error al procesar el mensaje de voz." + finally: + if os.path.exists(file_path): + os.remove(file_path) + + result = flow_engine.handle_response(user_id, user_response) + + if result["status"] == "in_progress": + await send_step_message(update, result["step"]) + elif result["status"] == "complete": + if "sales_pitch" in result: + await update.message.reply_text(result["sales_pitch"]) + elif "nfc_tag" in result: + await update.message.reply_text(result["nfc_tag"], parse_mode='Markdown') + else: + await update.message.reply_text("Gracias por completar el flujo.") + elif result["status"] == "error": + await update.message.reply_text(result["message"]) diff --git a/bot/modules/printer.py b/bot/modules/printer.py index b2b2e9e..56fbcbb 100644 --- a/bot/modules/printer.py +++ b/bot/modules/printer.py @@ -5,10 +5,13 @@ import smtplib import imaplib import email import logging +import os from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.base import MIMEBase from email import encoders +from telegram import Update +from telegram.ext import ContextTypes from bot.config import ( SMTP_SERVER, @@ -21,9 +24,36 @@ from bot.config import ( PRINTER_EMAIL, ) from bot.modules.identity import is_admin +from bot.modules.file_validation import validate_document logger = logging.getLogger(__name__) +async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handles documents sent to the bot for printing.""" + document = update.message.document + user_id = update.effective_user.id + + is_valid, message = validate_document(document) + if not is_valid: + await update.message.reply_text(message) + return + + file = await context.bot.get_file(document.file_id) + + temp_dir = 'temp_files' + os.makedirs(temp_dir, exist_ok=True) + file_path = os.path.join(temp_dir, document.file_name) + + try: + await file.download_to_drive(file_path) + + response = await send_file_to_printer(file_path, user_id, document.file_name) + await update.message.reply_text(response) + finally: + if os.path.exists(file_path): + os.remove(file_path) + + async def send_file_to_printer(file_path: str, user_id: int, file_name: str): """ Sends a file to the printer via email. @@ -93,6 +123,9 @@ async def check_print_status(user_id: int): if not email_ids: return "No hay actualizaciones de estado de impresión." + # Fetch only the last 10 unseen emails + email_ids = email_ids[-10:] + statuses = [] for e_id in email_ids: _, msg_data = mail.fetch(e_id, "(RFC822)") @@ -108,7 +141,10 @@ async def check_print_status(user_id: int): statuses.append(f"Trabajo de impresión recibido: {msg['subject']}") else: statuses.append(f"Nuevo correo: {msg['subject']}") + # Mark the email as seen + mail.store(e_id, "+FLAGS", "\\Seen") + mail.close() mail.logout() if not statuses: