mirror of
https://github.com/marcogll/talia_bot.git
synced 2026-01-13 13:25:19 +00:00
feat: Complete sprint 2 tasks
- Upgrade Python to 3.11 and update dependencies. - Refactor main.py to isolate business logic. - Fix bugs in flow_engine.py and printer.py. - Improve database connection handling. - Standardize error handling. - Verify secret management.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Python base image
|
||||
FROM python:3.9-slim
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /talia_bot
|
||||
|
||||
22
Tasks.md
22
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
|
||||
|
||||
19
bot/db.py
19
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
|
||||
|
||||
246
bot/main.py
246
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()
|
||||
|
||||
138
bot/modules/dispatcher.py
Normal file
138
bot/modules/dispatcher.py
Normal 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')
|
||||
@@ -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}")
|
||||
|
||||
57
bot/modules/message_handler.py
Normal file
57
bot/modules/message_handler.py
Normal 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"])
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user