Merge pull request #57 from marcogll/feature/sprint-updates-20240112-15660961988964187929

Feature/sprint updates 20240112 15660961988964187929
This commit is contained in:
Marco Gallegos
2025-12-22 14:56:31 -06:00
committed by GitHub
8 changed files with 312 additions and 269 deletions

View File

@@ -1,5 +1,5 @@
# Python base image # Python base image
FROM python:3.9-slim FROM python:3.11-slim
# Set working directory # Set working directory
WORKDIR /talia_bot WORKDIR /talia_bot

View File

@@ -13,6 +13,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
- **Due**: ASAP - **Due**: ASAP
### [SEC-002] Hardcoded Secrets Management ### [SEC-002] Hardcoded Secrets Management
- **Status**: TODO
- **Priority**: High - **Priority**: High
- **Description**: Email credentials stored in plain text environment variables - **Description**: Email credentials stored in plain text environment variables
- **Files affected**: `config.py`, `.env.example` - **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 - **Due**: Next sprint
### [SEC-003] SQL Injection Prevention ### [SEC-003] SQL Injection Prevention
- **Status**: TODO
- **Priority**: Medium - **Priority**: Medium
- **Description**: Database connection lacks connection pooling and timeout configurations - **Description**: Database connection lacks connection pooling and timeout configurations
- **Files affected**: `db.py` - **Files affected**: `db.py`
@@ -37,6 +39,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
- **Due**: Next sprint - **Due**: Next sprint
### [IMP-002] Dynamic Menu Generation ### [IMP-002] Dynamic Menu Generation
- **Status**: TODO
- **Priority**: Medium - **Priority**: Medium
- **Description**: `onboarding.py` has hardcoded menus instead of dynamic generation - **Description**: `onboarding.py` has hardcoded menus instead of dynamic generation
- **Files affected**: `onboarding.py` - **Files affected**: `onboarding.py`
@@ -44,6 +47,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
- **Due**: Future iteration - **Due**: Future iteration
### [IMP-003] Button Dispatcher Agent ### [IMP-003] Button Dispatcher Agent
- **Status**: TODO
- **Priority**: Low - **Priority**: Low
- **Description**: "Despachador de Botones" mentioned in AGENTS.md but not implemented - **Description**: "Despachador de Botones" mentioned in AGENTS.md but not implemented
- **Files affected**: Need to create new module - **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** 🟠 ## **Architecture & Code Quality** 🟠
### [ARCH-001] Main.py Business Logic Violation ### [ARCH-001] Main.py Business Logic Violation
- **Status**: TODO
- **Priority**: Medium - **Priority**: Medium
- **Description**: `main.py` contains business logic (lines 56-95) violating "Recepcionista" agent role - **Description**: `main.py` contains business logic (lines 56-95) violating "Recepcionista" agent role
- **Files affected**: `main.py` - **Files affected**: `main.py`
@@ -60,6 +65,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
- **Due**: Next sprint - **Due**: Next sprint
### [ARCH-002] Error Handling Consistency ### [ARCH-002] Error Handling Consistency
- **Status**: TODO
- **Priority**: Medium - **Priority**: Medium
- **Description**: Inconsistent error handling across modules, missing try-catch blocks - **Description**: Inconsistent error handling across modules, missing try-catch blocks
- **Files affected**: `flow_engine.py`, `printer.py`, multiple modules - **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 - **Due**: Next sprint
### [ARCH-003] Code Duplication ### [ARCH-003] Code Duplication
- **Status**: TODO
- **Priority**: Low - **Priority**: Low
- **Description**: Database connection patterns repeated, similar menu generation logic - **Description**: Database connection patterns repeated, similar menu generation logic
- **Files affected**: Multiple files - **Files affected**: Multiple files
@@ -76,6 +83,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
## **Performance & Optimization** 🟢 ## **Performance & Optimization** 🟢
### [PERF-001] Database Connection Pooling ### [PERF-001] Database Connection Pooling
- **Status**: TODO
- **Priority**: Medium - **Priority**: Medium
- **Description**: No connection pooling, missing indexes on frequently queried columns - **Description**: No connection pooling, missing indexes on frequently queried columns
- **Files affected**: `db.py` - **Files affected**: `db.py`
@@ -83,6 +91,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
- **Due**: Next sprint - **Due**: Next sprint
### [PERF-002] Memory Management ### [PERF-002] Memory Management
- **Status**: TODO
- **Priority**: Medium - **Priority**: Medium
- **Description**: Voice files loaded entirely into memory, no cleanup for failed uploads - **Description**: Voice files loaded entirely into memory, no cleanup for failed uploads
- **Files affected**: `llm_engine.py`, `main.py` - **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 - **Due**: Next sprint
### [PERF-003] Flow Engine Memory Usage ### [PERF-003] Flow Engine Memory Usage
- **Status**: TODO
- **Priority**: Low - **Priority**: Low
- **Description**: Flow engine stores all conversation data in memory - **Description**: Flow engine stores all conversation data in memory
- **Files affected**: `flow_engine.py` - **Files affected**: `flow_engine.py`
@@ -99,6 +109,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
## **Dependencies & Configuration** 🔵 ## **Dependencies & Configuration** 🔵
### [DEP-001] Python Version Upgrade ### [DEP-001] Python Version Upgrade
- **Status**: TODO
- **Priority**: High - **Priority**: High
- **Description**: Using Python 3.9 (EOL October 2025) - should upgrade to 3.11+ - **Description**: Using Python 3.9 (EOL October 2025) - should upgrade to 3.11+
- **Files affected**: `Dockerfile`, `requirements.txt` - **Files affected**: `Dockerfile`, `requirements.txt`
@@ -114,6 +125,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
- **Due**: ASAP - **Due**: ASAP
### [DEP-003] Docker Security Hardening ### [DEP-003] Docker Security Hardening
- **Status**: TODO
- **Priority**: Medium - **Priority**: Medium
- **Description**: Running as root user, missing security hardening - **Description**: Running as root user, missing security hardening
- **Files affected**: `Dockerfile`, `docker-compose.yml` - **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** 🐛 ## **Bugs & Logical Errors** 🐛
### [BUG-001] Flow Engine Validation ### [BUG-001] Flow Engine Validation
- **Status**: TODO
- **Priority**: Medium - **Priority**: Medium
- **Description**: `flow_engine.py:72` missing validation for empty steps array - **Description**: `flow_engine.py:72` missing validation for empty steps array
- **Files affected**: `flow_engine.py` - **Files affected**: `flow_engine.py`
@@ -130,6 +143,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
- **Due**: Next sprint - **Due**: Next sprint
### [BUG-002] Printer Module IMAP Search ### [BUG-002] Printer Module IMAP Search
- **Status**: TODO
- **Priority**: Medium - **Priority**: Medium
- **Description**: `printer.py:88` IMAP search doesn't handle large email counts - **Description**: `printer.py:88` IMAP search doesn't handle large email counts
- **Files affected**: `printer.py` - **Files affected**: `printer.py`
@@ -137,6 +151,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
- **Due**: Next sprint - **Due**: Next sprint
### [BUG-003] Identity Module String Comparison ### [BUG-003] Identity Module String Comparison
- **Status**: TODO
- **Priority**: Low - **Priority**: Low
- **Description**: `identity.py:42` string comparison for ADMIN_ID could fail if numeric - **Description**: `identity.py:42` string comparison for ADMIN_ID could fail if numeric
- **Files affected**: `identity.py` - **Files affected**: `identity.py`
@@ -146,6 +161,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
## **Documentation & Testing** 📚 ## **Documentation & Testing** 📚
### [DOC-001] Documentation Consistency ### [DOC-001] Documentation Consistency
- **Status**: TODO
- **Priority**: Low - **Priority**: Low
- **Description**: AGENTS.md vs implementation inconsistencies - **Description**: AGENTS.md vs implementation inconsistencies
- **Files affected**: `AGENTS.md`, various modules - **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 - **Due**: Future iteration
### [TEST-001] Test Coverage ### [TEST-001] Test Coverage
- **Status**: TODO
- **Priority**: Low - **Priority**: Low
- **Description**: Missing comprehensive test coverage - **Description**: Missing comprehensive test coverage
- **Files affected**: All modules - **Files affected**: All modules
@@ -160,6 +177,7 @@ This document tracks all pending tasks, improvements, and issues identified in t
- **Due**: Future iteration - **Due**: Future iteration
### [TEST-002] Code Quality Tools ### [TEST-002] Code Quality Tools
- **Status**: TODO
- **Priority**: Low - **Priority**: Low
- **Description**: Missing code quality tools (black, flake8, mypy) - **Description**: Missing code quality tools (black, flake8, mypy)
- **Files affected**: Repository configuration - **Files affected**: Repository configuration
@@ -170,12 +188,12 @@ This document tracks all pending tasks, improvements, and issues identified in t
## **Sprint Planning** ## **Sprint Planning**
### **Current Sprint (High Priority)** ### **Previous Sprint (High Priority)**
- **[DONE]** [SEC-001] File upload security validation - **[DONE]** [SEC-001] File upload security validation
- **[DONE]** [DEP-002] Package security updates - **[DONE]** [DEP-002] Package security updates
- **[DONE]** [IMP-001] Whisper transcription agent - **[DONE]** [IMP-001] Whisper transcription agent
### **Next Sprint (Medium Priority)** ### **Current Sprint (Medium Priority)**
- [SEC-002] Secret management implementation - [SEC-002] Secret management implementation
- [SEC-003] Database connection pooling - [SEC-003] Database connection pooling
- [DEP-001] Python version upgrade - [DEP-001] Python version upgrade

View File

@@ -4,16 +4,27 @@
import sqlite3 import sqlite3
import logging import logging
import os import os
import threading
DATABASE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "users.db") DATABASE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Use a thread-local object to manage the database connection
local = threading.local()
def get_db_connection(): def get_db_connection():
"""Creates a connection to the SQLite database.""" """Creates a connection to the SQLite database."""
conn = sqlite3.connect(DATABASE_FILE) if not hasattr(local, "conn"):
conn.row_factory = sqlite3.Row local.conn = sqlite3.connect(DATABASE_FILE, check_same_thread=False)
return conn 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(): def setup_database():
"""Sets up the database tables if they don't exist.""" """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}") logger.error(f"Database error during setup: {e}")
finally: finally:
if conn: if conn:
conn.close() close_db_connection()
if __name__ == '__main__': if __name__ == '__main__':
# This allows us to run the script directly to initialize the database # This allows us to run the script directly to initialize the database

View File

@@ -2,19 +2,16 @@
# Este es el archivo principal del bot. Aquí se inicia todo y se configuran los comandos. # Este es el archivo principal del bot. Aquí se inicia todo y se configuran los comandos.
import logging import logging
import asyncio
import sys import sys
from pathlib import Path from pathlib import Path
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram import Update
from telegram.ext import ( from telegram.ext import (
Application, Application,
CommandHandler, CommandHandler,
CallbackQueryHandler, CallbackQueryHandler,
ConversationHandler,
MessageHandler, MessageHandler,
ContextTypes, ContextTypes,
filters, filters,
TypeHandler,
) )
# Ensure package imports work even if the file is executed directly # 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.config import TELEGRAM_BOT_TOKEN
from bot.modules.identity import get_user_role from bot.modules.identity import get_user_role
from bot.modules.onboarding import handle_start as onboarding_handle_start from bot.modules.onboarding import handle_start as onboarding_handle_start
from bot.modules.onboarding import get_admin_secondary_menu from bot.modules.printer import handle_document, check_print_status
from bot.modules.agenda import get_agenda from bot.db import setup_database, close_db_connection
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.flow_engine import FlowEngine from bot.modules.flow_engine import FlowEngine
from bot.modules.transcription import transcribe_audio from bot.modules.dispatcher import button_dispatcher
from bot.modules.file_validation import validate_document from bot.modules.message_handler import text_and_voice_handler
from bot.scheduler import schedule_daily_summary from bot.scheduler import schedule_daily_summary
# Configuramos el sistema de logs para ver mensajes de estado en la consola # Configuramos el sistema de logs para ver mensajes de estado en la consola
@@ -53,47 +38,6 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) 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: async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
""" """
Se ejecuta cuando el usuario escribe /start. 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.") logger.info(f"User {chat_id} started a new conversation, clearing any previous state.")
user_role = get_user_role(chat_id) user_role = get_user_role(chat_id)
logger.info(f"Usuario {chat_id} inició conversación con el rol: {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 # 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) 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: async def check_print_status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Command to check print status.""" """Command to check print status."""
user_id = update.effective_user.id 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.") 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: def main() -> None:
"""Función principal que arranca el bot.""" """Función principal que arranca el bot."""
if not TELEGRAM_BOT_TOKEN: if not TELEGRAM_BOT_TOKEN:
@@ -310,25 +87,24 @@ def main() -> None:
application = Application.builder().token(TELEGRAM_BOT_TOKEN).build() application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
# Instantiate and store the flow engine in bot_data
flow_engine = FlowEngine() flow_engine = FlowEngine()
application.bot_data["flow_engine"] = flow_engine application.bot_data["flow_engine"] = flow_engine
schedule_daily_summary(application) schedule_daily_summary(application)
application.add_handler(CommandHandler("start", start)) application.add_handler(CommandHandler("start", start))
application.add_handler(CommandHandler("reset", reset_conversation)) # Added reset command application.add_handler(CommandHandler("reset", reset_conversation))
application.add_handler(CommandHandler("print", print_handler))
application.add_handler(CommandHandler("check_print_status", check_print_status_command)) 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.Document.ALL, handle_document))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND | filters.VOICE, text_and_voice_handler)) application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND | filters.VOICE, text_and_voice_handler))
application.add_handler(CallbackQueryHandler(button_dispatcher)) application.add_handler(CallbackQueryHandler(button_dispatcher))
logger.info("Iniciando Talía Bot...") logger.info("Iniciando Talía Bot...")
try:
application.run_polling() application.run_polling()
finally:
close_db_connection()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

138
bot/modules/dispatcher.py Normal file
View File

@@ -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')

View File

@@ -52,11 +52,11 @@ class FlowEngine:
def get_conversation_state(self, user_id): def get_conversation_state(self, user_id):
"""Gets the current conversation state for a user from the database.""" """Gets the current conversation state for a user from the database."""
try:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT flow_id, current_step_id, collected_data FROM conversations WHERE user_id = ?", (user_id,)) cursor.execute("SELECT flow_id, current_step_id, collected_data FROM conversations WHERE user_id = ?", (user_id,))
state = cursor.fetchone() state = cursor.fetchone()
conn.close()
if state: if state:
return { return {
"flow_id": state['flow_id'], "flow_id": state['flow_id'],
@@ -64,12 +64,15 @@ class FlowEngine:
"collected_data": json.loads(state['collected_data']) if state['collected_data'] else {} "collected_data": json.loads(state['collected_data']) if state['collected_data'] else {}
} }
return None 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): def start_flow(self, user_id, flow_id):
"""Starts a new flow for a user.""" """Starts a new flow for a user."""
flow = self.get_flow(flow_id) flow = self.get_flow(flow_id)
if not flow or 'steps' not in flow or not flow['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 or has no steps.") logger.error(f"Flow '{flow_id}' is invalid, has no steps, or steps is not a non-empty list.")
return None return None
initial_step = flow['steps'][0] initial_step = flow['steps'][0]
@@ -78,6 +81,7 @@ class FlowEngine:
def update_conversation_state(self, user_id, flow_id, step_id, collected_data): def update_conversation_state(self, user_id, flow_id, step_id, collected_data):
"""Creates or updates the conversation state in the database.""" """Creates or updates the conversation state in the database."""
try:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
@@ -85,7 +89,8 @@ class FlowEngine:
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
""", (user_id, flow_id, step_id, json.dumps(collected_data))) """, (user_id, flow_id, step_id, json.dumps(collected_data)))
conn.commit() conn.commit()
conn.close() except sqlite3.Error as e:
logger.error(f"Database error in update_conversation_state: {e}")
def handle_response(self, user_id, response_data): def handle_response(self, user_id, response_data):
""" """
@@ -147,8 +152,10 @@ class FlowEngine:
def end_flow(self, user_id): def end_flow(self, user_id):
"""Ends a flow for a user by deleting their conversation state.""" """Ends a flow for a user by deleting their conversation state."""
try:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM conversations WHERE user_id = ?", (user_id,)) cursor.execute("DELETE FROM conversations WHERE user_id = ?", (user_id,))
conn.commit() conn.commit()
conn.close() except sqlite3.Error as e:
logger.error(f"Database error in end_flow: {e}")

View File

@@ -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"])

View File

@@ -5,10 +5,13 @@ import smtplib
import imaplib import imaplib
import email import email
import logging import logging
import os
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email import encoders from email import encoders
from telegram import Update
from telegram.ext import ContextTypes
from bot.config import ( from bot.config import (
SMTP_SERVER, SMTP_SERVER,
@@ -21,9 +24,36 @@ from bot.config import (
PRINTER_EMAIL, PRINTER_EMAIL,
) )
from bot.modules.identity import is_admin from bot.modules.identity import is_admin
from bot.modules.file_validation import validate_document
logger = logging.getLogger(__name__) 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): async def send_file_to_printer(file_path: str, user_id: int, file_name: str):
""" """
Sends a file to the printer via email. Sends a file to the printer via email.
@@ -93,6 +123,9 @@ async def check_print_status(user_id: int):
if not email_ids: if not email_ids:
return "No hay actualizaciones de estado de impresión." return "No hay actualizaciones de estado de impresión."
# Fetch only the last 10 unseen emails
email_ids = email_ids[-10:]
statuses = [] statuses = []
for e_id in email_ids: for e_id in email_ids:
_, msg_data = mail.fetch(e_id, "(RFC822)") _, 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']}") statuses.append(f"Trabajo de impresión recibido: {msg['subject']}")
else: else:
statuses.append(f"Nuevo correo: {msg['subject']}") statuses.append(f"Nuevo correo: {msg['subject']}")
# Mark the email as seen
mail.store(e_id, "+FLAGS", "\\Seen")
mail.close()
mail.logout() mail.logout()
if not statuses: if not statuses: