refactor: Migrate bot core and modules from talia_bot to bot directory, update start_bot.sh and Dockerfile, and modify README.md.

This commit is contained in:
Marco Gallegos
2025-12-21 18:00:31 -06:00
parent 6e1a2f0102
commit 13141d6ed3
38 changed files with 112 additions and 86 deletions

1
bot/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
data/*.db

2
bot/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# bot/__init__.py
# Package initializer for the bot application.

52
bot/config.py Normal file
View File

@@ -0,0 +1,52 @@
# bot/config.py
# This file loads all environment variables and bot configurations.
# Environment variables are stored securely outside the code (e.g., in a .env file).
import os
from dotenv import load_dotenv
from pathlib import Path
# Load environment variables from the .env file in the project root
env_path = Path(__file__).parent.parent / '.env'
load_dotenv(dotenv_path=env_path)
# --- Telegram Configuration ---
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
ADMIN_ID = os.getenv("TELEGRAM_OWNER_CHAT_ID") # Renamed for consistency in the code
# --- Google Services ---
GOOGLE_SERVICE_ACCOUNT_FILE = os.getenv("GOOGLE_SERVICE_ACCOUNT_FILE")
if GOOGLE_SERVICE_ACCOUNT_FILE and not os.path.isabs(GOOGLE_SERVICE_ACCOUNT_FILE):
GOOGLE_SERVICE_ACCOUNT_FILE = str(Path(__file__).parent.parent / GOOGLE_SERVICE_ACCOUNT_FILE)
WORK_GOOGLE_CALENDAR_ID = os.getenv("WORK_GOOGLE_CALENDAR_ID")
PERSONAL_GOOGLE_CALENDAR_ID = os.getenv("PERSONAL_GOOGLE_CALENDAR_ID")
# --- Webhooks (n8n) ---
N8N_WEBHOOK_URL = os.getenv("N8N_WEBHOOK_URL")
N8N_TEST_WEBHOOK_URL = os.getenv("N8N_WEBHOOK_TEST_URL")
# --- AI Core ---
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
DAILY_SUMMARY_TIME = os.getenv("AI_DAILY_SUMMARY_TIME", "08:00")
TIMEZONE = os.getenv("TIMEZONE", "America/Monterrey")
# --- Scheduling ---
CALENDLY_LINK = os.getenv("CALENDLY_LINK")
# --- Vikunja (Task Management) ---
VIKUNJA_API_URL = os.getenv("VIKUNJA_BASE_URL")
VIKUNJA_API_TOKEN = os.getenv("VIKUNJA_TOKEN")
# --- Email Configuration (SMTP / IMAP) ---
SMTP_SERVER = os.getenv("SMTP_SERVER")
SMTP_PORT = os.getenv("SMTP_PORT")
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASS = os.getenv("SMTP_PASSWORD")
IMAP_SERVER = os.getenv("IMAP_SERVER")
IMAP_USER = os.getenv("IMAP_USER")
IMAP_PASS = os.getenv("IMAP_PASSWORD")
# --- Printer (Epson Connect) ---
PRINTER_EMAIL = os.getenv("PRINTER_EMAIL")

View File

@@ -0,0 +1,25 @@
{
"id": "admin_block_agenda",
"role": "admin",
"trigger_button": "🛑 Bloquear Agenda",
"steps": [
{
"step_id": 0,
"variable": "BLOCK_DATE",
"question": "Necesito bloquear la agenda. ¿Para cuándo?",
"options": ["Hoy", "Mañana"]
},
{
"step_id": 1,
"variable": "BLOCK_TIME",
"question": "Dame el horario exacto que necesitas bloquear (ej. 'de 2pm a 4pm').",
"input_type": "text"
},
{
"step_id": 2,
"variable": "BLOCK_TITLE",
"question": "Finalmente, dame una breve descripción o motivo del bloqueo.",
"input_type": "text"
}
]
}

View File

@@ -0,0 +1,19 @@
{
"id": "admin_check_agenda",
"role": "admin",
"trigger_button": "📅 Revisar Agenda",
"steps": [
{
"step_id": 0,
"variable": "AGENDA_TIMEFRAME",
"question": "Consultando el oráculo del tiempo... ⏳",
"options": ["📅 Hoy", "🔮 Mañana"]
},
{
"step_id": 1,
"variable": "AGENDA_ACTION",
"question": "Aquí tienes tu realidad: {CALENDAR_DATA}",
"options": ["✅ Todo bien", "🛑 Bloquear Espacio"]
}
]
}

View File

@@ -0,0 +1,31 @@
{
"id": "admin_create_nfc_tag",
"role": "admin",
"trigger_button": "start_create_tag",
"steps": [
{
"step_id": 0,
"variable": "EMPLOYEE_NAME",
"question": "Vamos a crear un nuevo tag de empleado. Por favor, dime el nombre completo:",
"input_type": "text"
},
{
"step_id": 1,
"variable": "EMPLOYEE_ID",
"question": "Gracias. Ahora, por favor, dime el número de empleado:",
"input_type": "text"
},
{
"step_id": 2,
"variable": "BRANCH",
"question": "Entendido. Ahora, por favor, selecciona la sucursal:",
"options": ["🏢 Oficina", "📢 Aura Mkt", "💅 Vanity"]
},
{
"step_id": 3,
"variable": "TELEGRAM_ID",
"question": "Perfecto. Finalmente, por favor, dime el ID numérico de Telegram del empleado:",
"input_type": "text"
}
]
}

View File

@@ -0,0 +1,26 @@
{
"id": "admin_idea_capture",
"role": "admin",
"name": "💡 Capturar Idea",
"trigger_button": "capture_idea",
"steps": [
{
"step_id": 0,
"variable": "IDEA_CONTENT",
"question": "Te escucho. 💡 Las ideas vuelan...",
"input_type": "text_or_audio"
},
{
"step_id": 1,
"variable": "IDEA_CATEGORY",
"question": "¿En qué cajón mental guardamos esto?",
"options": ["💰 Negocio", "📹 Contenido", "👤 Personal"]
},
{
"step_id": 2,
"variable": "IDEA_ACTION",
"question": "¿Cuál es el plan de ataque?",
"options": ["✅ Crear Tarea", "📓 Guardar Nota"]
}
]
}

View File

@@ -0,0 +1,14 @@
{
"id": "admin_print_file",
"role": "admin",
"name": "🖨️ Imprimir Archivo",
"trigger_button": "print_file",
"steps": [
{
"step_id": 0,
"variable": "UPLOAD_FILE",
"question": "Por favor, envíame el archivo que quieres imprimir.",
"input_type": "document"
}
]
}

View File

@@ -0,0 +1,31 @@
{
"id": "admin_project_management",
"role": "admin",
"trigger_button": "manage_vikunja",
"steps": [
{
"step_id": 0,
"variable": "PROJECT_SELECT",
"question": "Aquí está el tablero de ajedrez...",
"input_type": "dynamic_keyboard_vikunja_projects"
},
{
"step_id": 1,
"variable": "TASK_SELECT",
"question": "Has seleccionado el proyecto {project_name}. ¿Qué quieres hacer?",
"input_type": "dynamic_keyboard_vikunja_tasks"
},
{
"step_id": 2,
"variable": "ACTION_TYPE",
"question": "¿Cuál es la jugada?",
"options": ["🔄 Actualizar Estatus", "💬 Agregar Comentario"]
},
{
"step_id": 3,
"variable": "UPDATE_CONTENT",
"question": "Adelante. Soy todo oídos.",
"input_type": "text_or_audio"
}
]
}

View File

@@ -0,0 +1,34 @@
{
"id": "client_sales_funnel",
"role": "client",
"trigger_button": "get_service_info",
"trigger_automatic": true,
"steps": [
{
"step_id": 0,
"variable": "CLIENT_NAME",
"question": "Hola, soy Talía. ✨ Estoy aquí para ayudarte a explorar cómo podemos potenciar tu negocio. Para empezar, ¿cuál es tu nombre?",
"input_type": "text"
},
{
"step_id": 1,
"variable": "CLIENT_INDUSTRY",
"question": "Mucho gusto. Para poder ofrecerte la solución más adecuada, por favor, selecciona el área que mejor describa tu negocio:",
"options": [
"🎨 Diseño Gráfico",
"🎬 Contenido Audiovisual",
"📈 Estrategias de Marketing",
"🌐 Desarrollo Web",
"🤖 Bots y Automatización",
"⚙️ Mejora de Procesos",
"💡 Otro"
]
},
{
"step_id": 2,
"variable": "IDEA_PITCH",
"question": "Excelente. Ahora, cuéntame sobre tu proyecto o la idea que tienes en mente. ¿Qué desafío buscas resolver o qué oportunidad quieres aprovechar? Puedes escribirlo o enviarme una nota de voz.",
"input_type": "text_or_audio"
}
]
}

View File

@@ -0,0 +1,13 @@
{
"id": "crew_print_file",
"role": "crew",
"trigger_button": "🖨️ Imprimir",
"steps": [
{
"step_id": 0,
"variable": "UPLOAD_FILE",
"question": "Claro, envíame el archivo que necesitas imprimir y yo me encargo.",
"input_type": "document"
}
]
}

View File

@@ -0,0 +1,29 @@
{
"id": "crew_propose_activity",
"description": "Permite a un miembro del equipo proponer una actividad para aprobación.",
"trigger_button": "propose_activity",
"user_roles": ["crew"],
"steps": [
{
"step_id": "get_description",
"question": "Por favor, describe la actividad que quieres proponer.",
"input_type": "text",
"next_step": "get_duration"
},
{
"step_id": "get_duration",
"question": "Entendido. Ahora, por favor, indica la duración estimada en horas (ej. 2, 4.5).",
"input_type": "text",
"next_step": "confirm_proposal"
},
{
"step_id": "confirm_proposal",
"question": "Gracias. Se ha enviado la siguiente propuesta para aprobación:\n\n📝 *Actividad:* {get_description}\n⏳ *Duración:* {get_duration} horas\n\nRecibirás una notificación cuando sea revisada.",
"input_type": "static"
}
],
"completion_action": {
"action_type": "notify_admin",
"message_template": "Nueva propuesta de actividad:\n\nDescripción: {get_description}\nDuración: {get_duration} horas"
}
}

View File

@@ -0,0 +1,31 @@
{
"id": "crew_request_time",
"role": "crew",
"trigger_button": "📅 Solicitar Agenda",
"steps": [
{
"step_id": 0,
"variable": "REQUEST_TYPE",
"question": "Para usar la agenda del estudio, necesito que seas preciso.",
"options": ["🎥 Grabación", "🎙️ Locución", "🎬 Edición", "🛠️ Mantenimiento"]
},
{
"step_id": 1,
"variable": "REQUEST_DATE",
"question": "¿Para cuándo necesitas el espacio?",
"options": ["Hoy", "Mañana", "Esta Semana"]
},
{
"step_id": 2,
"variable": "REQUEST_TIME",
"question": "Dame el horario exacto que necesitas (ej. 'de 10am a 2pm').",
"input_type": "text"
},
{
"step_id": 3,
"variable": "REQUEST_JUSTIFICATION",
"question": "Entendido. Antes de confirmar, necesito que me expliques brevemente el plan o el motivo para justificar el bloqueo del espacio. Puedes escribirlo o enviarme un audio.",
"input_type": "text_or_audio"
}
]
}

View File

@@ -0,0 +1,37 @@
{
"id": "crew_secret_onboarding",
"role": "crew",
"trigger_command": "/abracadabra",
"steps": [
{
"step_id": 0,
"variable": "ONBOARD_START",
"question": "Vaya, vaya... Parece que conoces el comando secreto. 🎩. Antes de continuar, necesito saber tu nombre completo.",
"input_type": "text"
},
{
"step_id": 1,
"variable": "ONBOARD_ORIGIN",
"question": "Un placer, {user_name}. ¿Cuál es tu base de operaciones principal?",
"options": ["🏢 Office", "✨ Aura"]
},
{
"step_id": 2,
"variable": "ONBOARD_EMAIL",
"question": "Perfecto. Ahora necesito tu correo electrónico de la empresa.",
"input_type": "text"
},
{
"step_id": 3,
"variable": "ONBOARD_PHONE",
"question": "Y por último, tu número de teléfono.",
"input_type": "text"
},
{
"step_id": 4,
"variable": "ONBOARD_CONFIRM",
"question": "Gracias. He enviado una notificación al Administrador para que apruebe tu acceso. En cuanto lo haga, tendrás acceso completo. ¡Bienvenido a bordo!",
"options": ["✅ Entendido"]
}
]
}

145
bot/data/services.json Normal file
View File

@@ -0,0 +1,145 @@
[
{
"service_name": "Diseño gráfico",
"description": "Soluciones completas de identidad visual y material gráfico para fortalecer la imagen de marca en medios digitales e impresos.",
"keywords": [
"branding",
"logotipos",
"identidad visual",
"diseño publicitario",
"manual de marca",
"formatos impresos"
],
"work_examples": [
"Creación de Identidad Visual (Logotipo, paleta de colores, tipografías)",
"Diseño de Manual de Marca y aplicaciones",
"Diseño de Flyers y Trípticos promocionales",
"Diseño de Tarjetas de Presentación (Digitales e Impresas)",
"Diseño de Banners para campañas publicitarias y redes sociales",
"Adaptación de artes para diferentes formatos (Instagram, LinkedIn, Web)"
]
},
{
"service_name": "Producción de contenido audiovisual",
"description": "Creación, edición y postproducción de contenido de video profesional enfocado en retener la atención y comunicar mensajes clave.",
"keywords": [
"video marketing",
"reels",
"tiktok",
"edición de video",
"podcast",
"youtube",
"guiones",
"postproducción"
],
"work_examples": [
"Edición dinámica de videos verticales (Reels/TikTok)",
"Producción y edición de episodios de Podcast",
"Creación de videos corporativos para YouTube",
"Animación de logotipos (Intros/Outros)",
"Subtitulado y efectos visuales para clips de redes sociales",
"Grabación y edición multicámara"
]
},
{
"service_name": "Estrategias de marketing",
"description": "Planificación y ejecución de estrategias de contenido para redes sociales, enfocadas en crecimiento, interacción y posicionamiento.",
"keywords": [
"social media",
"estrategia de contenidos",
"engagement",
"copywriting",
"calendario editorial",
"crecimiento orgánico"
],
"work_examples": [
"Diseño de parrilla de contenidos mensual",
"Creación de creativos gráficos para posts y carruseles",
"Redacción de copies persuasivos y llamadas a la acción (CTA)",
"Planificación de campañas de lanzamiento",
"Análisis de tendencias y competencia en redes sociales"
]
},
{
"service_name": "Desarrollo de páginas web",
"description": "Diseño y desarrollo de sitios web funcionales y optimizados, desde páginas de aterrizaje hasta tiendas en línea completas.",
"keywords": [
"desarrollo web",
"e-commerce",
"landing page",
"seo",
"ux/ui",
"tienda online",
"pasarelas de pago"
],
"work_examples": [
"Desarrollo de E-commerce con catálogo y pasarela de pagos",
"Diseño de Landing Pages optimizadas para conversión (Captación de Leads)",
"Creación de Sitios Web Corporativos institucionales",
"Integración de herramientas de análisis (Google Analytics, Pixel)",
"Optimización SEO básica y velocidad de carga",
"Mantenimiento y actualización de sitios web"
]
},
{
"service_name": "Bots para venta y agenda",
"description": "Implementación de asistentes virtuales inteligentes para gestionar clientes, automatizar ventas y agendar citas automáticamente.",
"keywords": [
"chatbots",
"whatsapp business",
"automatización de ventas",
"atención al cliente",
"flujos conversacionales",
"crm"
],
"work_examples": [
"Configuración de Bot de WhatsApp para atención 24/7",
"Automatización de agendamiento de citas y recordatorios",
"Diseño de flujos de conversación para calificación de leads",
"Respuestas automáticas a preguntas frecuentes (FAQs)",
"Integración de chatbot con base de datos de clientes",
"Segmentación automática de usuarios según sus respuestas"
]
},
{
"service_name": "Consultoria de mejora de procesos",
"description": "Servicios de consultoría estratégica para optimizar flujos de trabajo y tomar decisiones basadas en datos y análisis.",
"keywords": [
"consultoría de negocios",
"optimización de procesos",
"análisis de datos",
"encuestas",
"kpis",
"mejora continua"
],
"work_examples": [
"Implementación de encuestas de satisfacción de clientes",
"Análisis y reporte de métricas de negocio",
"Auditoría de procesos operativos actuales",
"Diseño de formularios interactivos para recolección de datos",
"Identificación de cuellos de botella y oportunidades de mejora"
]
},
{
"service_name": "Automatización de procesos e integración de IA a tu negocio",
"description": "Soluciones avanzadas de automatización de flujos de trabajo e integración de Inteligencia Artificial para reducir tareas repetitivas y escalar operaciones.",
"keywords": [
"inteligencia artificial",
"automatización",
"zapier",
"make",
"chatgpt",
"api",
"productividad",
"workflows"
],
"work_examples": [
"Conexión de aplicaciones mediante Zapier o Make (ej. Gmail a Slack)",
"Implementación de agentes de IA personalizados (GPTs) para tareas específicas",
"Automatización de generación de facturas y reportes",
"Clasificación y respuesta automática de correos electrónicos con IA",
"Extracción y procesamiento automático de datos de documentos",
"Integración de APIs de IA en sistemas internos de la empresa"
]
}
]

60
bot/db.py Normal file
View File

@@ -0,0 +1,60 @@
# bot/db.py
# This module will handle the database connection and operations.
import sqlite3
import logging
import os
DATABASE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
logger = logging.getLogger(__name__)
def get_db_connection():
"""Creates a connection to the SQLite database."""
conn = sqlite3.connect(DATABASE_FILE)
conn.row_factory = sqlite3.Row
return conn
def setup_database():
"""Sets up the database tables if they don't exist."""
conn = None
try:
conn = get_db_connection()
cursor = conn.cursor()
# Create the users table
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_id INTEGER UNIQUE NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'crew', 'client')),
name TEXT,
employee_id TEXT,
branch TEXT
)
""")
# Create the conversations table
cursor.execute("""
CREATE TABLE IF NOT EXISTS conversations (
user_id INTEGER PRIMARY KEY,
flow_id TEXT NOT NULL,
current_step_id INTEGER NOT NULL,
collected_data TEXT
)
""")
conn.commit()
logger.info("Database setup complete. 'users' table is ready.")
except sqlite3.Error as e:
logger.error(f"Database error during setup: {e}")
finally:
if conn:
conn.close()
if __name__ == '__main__':
# This allows us to run the script directly to initialize the database
logging.basicConfig(level=logging.INFO)
logger.info("Running database setup manually...")
setup_database()
logger.info("Manual setup finished.")

70
bot/debug.md Normal file
View File

@@ -0,0 +1,70 @@
# Debugging Report: Telegram Bot Conversational Flows
## Problem Description
The primary issue is that the Telegram bot is not engaging in conversational flows and is failing to respond to button presses, effectively ignoring "triggers" sent via inline keyboard buttons.
Initially, the bot exhibited Python runtime errors:
1. **`IndentationError: unexpected indent`** in `main.py`, specifically around the `ConversationHandler` definition.
2. **`SyntaxError: unterminated string literal`** in `main.py` due to an incomplete `pattern` in a `CallbackQueryHandler`.
3. **`AttributeError: 'Application' object has no attribute 'add_h_handler'`** due to a typo in `main.py`.
After addressing these syntax and indentation issues, the bot launched successfully without crashing. However, the core problem of unresponsive buttons and non-functional conversational flows persisted, with no relevant application logs appearing when buttons were pressed.
## Initial Diagnosis & Fixes
1. **Indentation and Syntax Errors:**
* The `main.py` file was found to have severely malformed code within the `main()` function, including duplicated sections and an incorrectly constructed `ConversationHandler`.
* The entire `main()` function and the `if __name__ == "__main__":` block were rewritten to correct these structural and syntactical errors, ensuring proper Python code execution. This included fixing the `IndentationError` and the `SyntaxError` in the `CallbackQueryHandler` pattern.
* A typo (`add_h_handler` instead of `add_handler`) causing an `AttributeError` was corrected.
2. **Lack of Application Logs:**
* To diagnose the unresponsive buttons, diagnostic `print` statements were added to the `button_dispatcher` in `main.py` and `propose_activity_start` in `modules/equipo.py`.
* A generic `TypeHandler` with a `catch_all_handler` was added to `main.py` to log all incoming updates from Telegram.
* Despite these additions, no diagnostic output appeared when buttons were pressed, indicating that the handlers were not being triggered.
## Deep Dive into Button Handling
* **Flows and Triggers:** Examination of `data/flows/admin_create_nfc_tag.json` confirmed that flows are triggered by specific `callback_data` (e.g., `start_create_tag`).
* **Button Definitions:** Review of `modules/onboarding.py` confirmed that buttons were correctly configured with `callback_data` values like `view_pending`, `start_create_tag`, and `propose_activity`.
* **Handler Registration:** The order and definition of handlers in `main.py` were reviewed:
* A `ConversationHandler` (for `propose_activity`) with a specific `CallbackQueryHandler` pattern (`^propose_activity$`).
* A generic `CallbackQueryHandler(button_dispatcher)` to catch other button presses.
* The order was deemed logically correct for dispatching.
## Isolation Attempts
To rule out interference from the main application's complexity, two simplified bot scripts were created and tested:
1. **`debug_main.py`:** A minimal bot that loaded the `TELEGRAM_BOT_TOKEN` and registered a simple `/start` command and a `CallbackQueryHandler`. This script failed to respond to button presses.
2. **`simplest_bot.py`:** An even more stripped-down, self-contained bot with the bot token hardcoded, designed only to respond to `/start` and a single "Test Me" button press. This script also failed to trigger its `CallbackQueryHandler`.
## Root Cause Identification
The consistent failure across all test cases (original bot, `debug_main.py`, `simplest_bot.py`), despite correct code logic, led to an investigation of the `python-telegram-bot` library version.
* `pip show python-telegram-bot` revealed that version `22.5` was installed.
* Research indicated that `python-telegram-bot` versions `22.x` are pre-release and contain significant breaking changes, including the removal of functionality deprecated in `v20.x`. This incompatibility was the likely cause of the handlers not being triggered.
## Solution
The `python-telegram-bot` library was downgraded to a stable version:
* Command executed: `pip install --force-reinstall "python-telegram-bot<22"`
* Verified installed version: `21.11.1`
## Current Status and Next Steps
Even after successfully downgrading the library, the bot *still* does not respond to button presses, and the diagnostic print statements are not being hit. This is highly unusual given the simplicity of the `simplest_bot.py` script.
This suggests that the updates from Telegram are still not reaching the application's handlers. The `deleteWebhook` command was executed and confirmed no active webhook exists.
**Remaining Suspicions:**
1. **Conflicting Bot Instance:** There might be another instance of this bot (using the same token) running somewhere else (e.g., on a different server, or another terminal on the same machine) that is consuming the updates before the current local process can receive them.
2. **Bot Token Issue:** In rare cases, a bot token itself can become "stuck" or problematic on Telegram's side, preventing updates from being reliably delivered.
**Next Steps:**
* **User Action Required:** The user must ensure with absolute certainty that no other instances of the bot (using the token `8065880723:AAHOYnTe0PlP6pkjBirK8REtDDlZOrhc-qw`) are currently running on any other machine or process.
* **If no other instances are found:** As a last resort, the user should revoke the current bot token via BotFather in Telegram and generate a completely new token. Then, update `config.py` (and `simplest_bot.py` if testing that again) with the new token.
* **Clean up diagnostic code:** Once the core issue is resolved, all temporary diagnostic print statements and files (`debug_main.py`, `simplest_bot.py`) will be removed.

326
bot/main.py Normal file
View File

@@ -0,0 +1,326 @@
# bot/main.py
# 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.ext import (
Application,
CommandHandler,
CallbackQueryHandler,
ConversationHandler,
MessageHandler,
ContextTypes,
filters,
TypeHandler,
)
# Ensure package imports work even if the file is executed directly
if __package__ is None:
current_dir = Path(__file__).resolve().parent
project_root = current_dir.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
# Importamos las configuraciones y herramientas que creamos en otros archivos
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.flow_engine import FlowEngine
from bot.modules.llm_engine import transcribe_audio
from bot.scheduler import schedule_daily_summary
# Configuramos el sistema de logs para ver mensajes de estado en la consola
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
)
logger = logging.getLogger(__name__)
async def 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.
Muestra un mensaje de bienvenida y un menú según el rol del usuario.
"""
chat_id = update.effective_chat.id
# Reset any existing conversation flow
flow_engine = context.bot_data.get("flow_engine")
if flow_engine:
flow_engine.end_flow(chat_id)
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
response_text, reply_markup = onboarding_handle_start(user_role, flow_engine)
# Respondemos al usuario
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
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
response = await check_print_status(user_id)
await update.message.reply_text(response)
async def reset_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Resets the conversation state for the user."""
user_id = update.effective_user.id
flow_engine = context.bot_data["flow_engine"]
flow_engine.end_flow(user_id)
await update.message.reply_text("🔄 Conversación reiniciada. Puedes empezar de nuevo.")
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:
logger.error("TELEGRAM_BOT_TOKEN no está configurado en las variables de entorno.")
return
setup_database()
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("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()
if __name__ == "__main__":
main()

20
bot/modules/admin.py Normal file
View File

@@ -0,0 +1,20 @@
# app/modules/admin.py
# Este módulo contiene funciones administrativas para el bot.
# Por ahora, permite ver el estado general del sistema.
def get_system_status():
"""
Devuelve un mensaje con el estado actual del bot y sus conexiones.
Actualmente el mensaje es fijo (hardcoded), pero en el futuro podría
hacer pruebas reales de conexión.
"""
# TODO: Implementar pruebas de estado en tiempo real para un monitoreo exacto.
status_text = (
"📊 *Estado del Sistema*\n\n"
"- *Bot Principal:* Activo ✅\n"
"- *Conexión Telegram API:* Estable ✅\n"
"- *Integración n8n:* Operacional ✅\n"
"- *Google Calendar:* Conectado ✅"
)
return status_text

56
bot/modules/agenda.py Normal file
View File

@@ -0,0 +1,56 @@
# bot/modules/agenda.py
# Este módulo se encarga de manejar las peticiones relacionadas con la agenda.
# Permite obtener y mostrar las actividades programadas para el día.
import datetime
import logging
from bot.modules.calendar import get_events
from bot.config import WORK_GOOGLE_CALENDAR_ID, PERSONAL_GOOGLE_CALENDAR_ID
logger = logging.getLogger(__name__)
async def get_agenda():
"""
Obtiene y muestra la agenda del usuario para el día actual desde Google Calendar.
Diferencia entre eventos de trabajo (visibles) y personales (bloqueos).
"""
try:
logger.info("Obteniendo agenda...")
now = datetime.datetime.now(datetime.timezone.utc)
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
end_of_day = start_of_day + datetime.timedelta(days=1)
logger.info(f"Buscando eventos de trabajo en {WORK_GOOGLE_CALENDAR_ID} y personales en {PERSONAL_GOOGLE_CALENDAR_ID}")
# Obtener eventos de trabajo (para mostrar)
work_events = get_events(start_of_day, end_of_day, calendar_id=WORK_GOOGLE_CALENDAR_ID)
# Obtener eventos personales (para comprobar bloqueos, no se muestran)
personal_events = get_events(start_of_day, end_of_day, calendar_id=PERSONAL_GOOGLE_CALENDAR_ID)
if not work_events and not personal_events:
logger.info("No se encontraron eventos de ningún tipo.")
return "📅 *Agenda para Hoy*\n\nTotalmente despejado. No hay eventos de trabajo ni personales."
agenda_text = "📅 *Agenda para Hoy*\n\n"
if not work_events:
agenda_text += "No tienes eventos de trabajo programados para hoy.\n"
else:
for event in work_events:
start = event["start"].get("dateTime", event["start"].get("date"))
if "T" in start:
time_str = start.split("T")[1][:5]
else:
time_str = "Todo el día"
summary = event.get("summary", "(Sin título)")
agenda_text += f"• *{time_str}* - {summary}\n"
if personal_events:
agenda_text += "\n🔒 Tienes tiempo personal bloqueado."
logger.info("Agenda obtenida con éxito.")
return agenda_text
except Exception as e:
logger.error(f"Error al obtener la agenda: {e}")
return "❌ Error al obtener la agenda. Por favor, intenta de nuevo más tarde."

View File

@@ -0,0 +1,69 @@
# app/modules/aprobaciones.py
# Este módulo gestiona el flujo de aprobación para las solicitudes hechas por el equipo.
# Permite ver solicitudes pendientes y aprobarlas o rechazarlas.
# El usuario principal aquí es el "owner" (dueño).
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
def get_approval_menu(request_id):
"""
Crea un menú de botones (teclado en línea) con "Aprobar" y "Rechazar".
Cada botón lleva el ID de la solicitud para saber cuál estamos procesando.
"""
keyboard = [
[
# callback_data es lo que el bot recibe cuando se pulsa el botón
InlineKeyboardButton("✅ Aprobar", callback_data=f'approve:{request_id}'),
InlineKeyboardButton("❌ Rechazar", callback_data=f'reject:{request_id}'),
]
]
return InlineKeyboardMarkup(keyboard)
def view_pending():
"""
Muestra al dueño una lista de solicitudes que esperan su aprobación.
Por ahora usa una lista fija de ejemplo.
"""
# TODO: Obtener solicitudes reales desde una base de datos o servicio externo.
proposals = [
{"id": "prop_001", "desc": "Grabación de proyecto", "duration": 4, "user": "Equipo A"},
{"id": "prop_002", "desc": "Taller de guion", "duration": 2, "user": "Equipo B"},
]
if not proposals:
return "No hay solicitudes pendientes.", None
# Tomamos la primera propuesta para mostrarla
proposal = proposals[0]
text = (
f"⏳ *Nueva Solicitud Pendiente*\n\n"
f"🙋‍♂️ *Solicitante:* {proposal['user']}\n"
f"📝 *Actividad:* {proposal['desc']}\n"
f"⏳ *Duración:* {proposal['duration']} horas"
)
# Adjuntamos los botones de aprobación
reply_markup = get_approval_menu(proposal['id'])
return text, reply_markup
def handle_approval_action(callback_data):
"""
Maneja la respuesta del dueño (clic en aprobar o rechazar).
Separa la acción (approve/reject) del ID de la solicitud.
"""
# callback_data viene como "accion:id", por ejemplo "approve:prop_001"
action, request_id = callback_data.split(':')
if action == 'approve':
# TODO: Guardar en base de datos que fue aprobada y avisar al equipo.
return f"✅ La solicitud *{request_id}* ha sido aprobada."
elif action == 'reject':
# TODO: Guardar en base de datos que fue rechazada y avisar al equipo.
return f"❌ La solicitud *{request_id}* ha sido rechazada."
return "Acción desconocida.", None

146
bot/modules/calendar.py Normal file
View File

@@ -0,0 +1,146 @@
# app/google_calendar.py
# Este script maneja la integración con Google Calendar (Calendario de Google).
# Permite buscar espacios libres y crear eventos.
import datetime
import logging
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from bot.config import GOOGLE_SERVICE_ACCOUNT_FILE, WORK_GOOGLE_CALENDAR_ID
logger = logging.getLogger(__name__)
# Configuración de los permisos (SCOPES) para acceder al calendario
SCOPES = ["https://www.googleapis.com/auth/calendar"]
# Autenticación usando el archivo de cuenta de servicio (Service Account)
creds = service_account.Credentials.from_service_account_file(
GOOGLE_SERVICE_ACCOUNT_FILE, scopes=SCOPES
)
# Construcción del objeto 'service' que nos permite interactuar con la API de Google Calendar
service = build("calendar", "v3", credentials=creds)
def get_available_slots(
start_time, end_time, duration_minutes=30, calendar_id=WORK_GOOGLE_CALENDAR_ID
):
"""
Busca espacios disponibles en el calendario dentro de un rango de tiempo.
Parámetros:
- start_time: Hora de inicio de la búsqueda.
- end_time: Hora de fin de la búsqueda.
- duration_minutes: Cuánto dura cada cita (por defecto 30 min).
- calendar_id: El ID del calendario donde buscar.
"""
try:
# Convertimos las fechas a formato ISO (el que entiende Google)
time_min = start_time.isoformat()
time_max = end_time.isoformat()
# Consultamos a Google qué horas están ocupadas (freebusy)
freebusy_query = {
"timeMin": time_min,
"timeMax": time_max,
"timeZone": "UTC",
"items": [{"id": calendar_id}],
}
freebusy_result = service.freebusy().query(body=freebusy_query).execute()
busy_slots = freebusy_result["calendars"][calendar_id]["busy"]
# Creamos una lista de todos los posibles espacios (slots)
potential_slots = []
current_time = start_time
while current_time + datetime.timedelta(minutes=duration_minutes) <= end_time:
potential_slots.append(
(
current_time,
current_time + datetime.timedelta(minutes=duration_minutes),
)
)
# Avanzamos el tiempo para el siguiente espacio
current_time += datetime.timedelta(minutes=duration_minutes)
# Filtramos los espacios que chocan con horas ocupadas
available_slots = []
for slot_start, slot_end in potential_slots:
is_busy = False
for busy in busy_slots:
busy_start = datetime.datetime.fromisoformat(busy["start"])
busy_end = datetime.datetime.fromisoformat(busy["end"])
# Si el espacio propuesto se cruza con uno ocupado, lo marcamos como ocupado
if max(slot_start, busy_start) < min(slot_end, busy_end):
is_busy = True
break
if not is_busy:
available_slots.append((slot_start, slot_end))
return available_slots
except HttpError as error:
print(f"Ocurrió un error con la API de Google: {error}")
return []
def create_event(summary, start_time, end_time, attendees, calendar_id=WORK_GOOGLE_CALENDAR_ID):
"""
Crea un nuevo evento (cita) en el calendario.
Parámetros:
- summary: Título del evento.
- start_time: Hora de inicio.
- end_time: Hora de fin.
- attendees: Lista de correos electrónicos de los asistentes.
"""
# Definimos la estructura del evento según pide Google
event = {
"summary": summary,
"start": {
"dateTime": start_time.isoformat(),
"timeZone": "UTC",
},
"end": {
"dateTime": end_time.isoformat(),
"timeZone": "UTC",
},
"attendees": [{"email": email} for email in attendees],
}
try:
# Insertamos el evento en el calendario
created_event = (
service.events().insert(calendarId=calendar_id, body=event).execute()
)
return created_event
except HttpError as error:
print(f"Ocurrió un error al crear el evento: {error}")
return None
def get_events(start_time, end_time, calendar_id=WORK_GOOGLE_CALENDAR_ID):
"""
Obtiene la lista de eventos entre dos momentos.
"""
try:
logger.info(f"Llamando a la API de Google Calendar para {calendar_id}")
events_result = (
service.events()
.list(
calendarId=calendar_id,
timeMin=start_time.isoformat(),
timeMax=end_time.isoformat(),
singleEvents=True,
orderBy="startTime",
)
.execute()
)
events = events_result.get("items", [])
logger.info(f"Se obtuvieron {len(events)} eventos de la API.")
return events
except HttpError as error:
logger.error(f"Ocurrió un error al obtener eventos: {error}")
return []
except Exception as e:
logger.error(f"Error inesperado al obtener eventos: {e}")
return []

17
bot/modules/citas.py Normal file
View File

@@ -0,0 +1,17 @@
# app/modules/citas.py
# Este módulo maneja la programación de citas para los clientes.
# Permite a los usuarios obtener un enlace para agendar una reunión.
from bot.config import CALENDLY_LINK
def request_appointment():
"""
Proporciona al usuario un enlace para agendar una cita.
Usa el enlace configurado en las variables de entorno.
"""
response_text = (
"Para agendar una cita, por favor utiliza el siguiente enlace: \n\n"
f"[Agendar Cita]({CALENDLY_LINK})"
)
return response_text

36
bot/modules/debug.py Normal file
View File

@@ -0,0 +1,36 @@
# bot/modules/debug.py
# Este módulo permite a los administradores imprimir los detalles de configuración del bot.
# Es una herramienta útil para depuración (debugging).
from telegram import Update
from telegram.ext import ContextTypes
from bot.modules.identity import is_admin
from bot.config import (
TIMEZONE,
WORK_GOOGLE_CALENDAR_ID,
PERSONAL_GOOGLE_CALENDAR_ID,
N8N_WEBHOOK_URL,
)
async def print_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""
Maneja el comando /print.
Verifica si el usuario es administrador. Si lo es, muestra valores clave
de la configuración (Zona horaria, ID de calendario, Webhook).
"""
chat_id = update.effective_chat.id
# Solo permitimos esto a los administradores
if is_admin(chat_id):
config_details = (
f"**Detalles de Configuración**\n"
f"Zona Horaria: `{TIMEZONE}`\n"
f"Calendario Trabajo: `{WORK_GOOGLE_CALENDAR_ID or 'No definido'}`\n"
f"Calendario Personal: `{PERSONAL_GOOGLE_CALENDAR_ID or 'No definido'}`\n"
f"URL Webhook n8n: `{N8N_WEBHOOK_URL or 'No definido'}`\n"
)
await update.message.reply_text(config_details, parse_mode='Markdown')
else:
# Si no es admin, le avisamos que no tiene permiso
await update.message.reply_text("No tienes autorización para usar este comando.")

12
bot/modules/equipo.py Normal file
View File

@@ -0,0 +1,12 @@
# app/modules/equipo.py
# Este módulo contiene funciones para los miembros autorizados del equipo.
# Incluye un flujo para proponer actividades que el dueño debe aprobar.
def view_requests_status():
"""
Permite a un miembro del equipo ver el estado de sus solicitudes recientes.
Por ahora devuelve un estado de ejemplo fijo.
"""
# TODO: Obtener el estado real desde una base de datos.
return "Aquí está el estado de tus solicitudes recientes:\n\n- Grabación de proyecto (4h): Aprobado\n- Taller de guion (2h): Pendiente"

154
bot/modules/flow_engine.py Normal file
View File

@@ -0,0 +1,154 @@
# bot/modules/flow_engine.py
import json
import logging
import os
from bot.db import get_db_connection
from bot.modules.sales_rag import generate_sales_pitch
from bot.modules.nfc_tag import generate_nfc_tag
logger = logging.getLogger(__name__)
class FlowEngine:
def __init__(self):
self.flows = self._load_flows()
def _load_flows(self):
"""Loads all individual flow JSON files from the flows directory."""
# flows_dir = 'bot/data/flows' # OLD
base_dir = os.path.dirname(os.path.abspath(__file__))
flows_dir = os.path.join(base_dir, '..', 'data', 'flows')
loaded_flows = []
try:
if not os.path.exists(flows_dir):
logger.error(f"Flows directory not found at '{flows_dir}'")
return []
for filename in os.listdir(flows_dir):
if filename.endswith('.json'):
file_path = os.path.join(flows_dir, filename)
try:
with open(file_path, 'r', encoding='utf-8') as f:
flow_data = json.load(f)
if 'role' not in flow_data:
logger.warning(f"Flow {filename} is missing a 'role' key. Skipping.")
continue
loaded_flows.append(flow_data)
except json.JSONDecodeError:
logger.error(f"Error decoding JSON from {filename}.")
except Exception as e:
logger.error(f"Error loading flow from {filename}: {e}")
logger.info(f"Successfully loaded {len(loaded_flows)} flows.")
return loaded_flows
except Exception as e:
logger.error(f"Failed to load flows from directory {flows_dir}: {e}")
return []
def get_flow(self, flow_id):
"""Retrieves a specific flow by its ID."""
return next((flow for flow in self.flows if flow.get('id') == flow_id), None)
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
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.")
return None
initial_step = flow['steps'][0]
self.update_conversation_state(user_id, flow_id, initial_step['step_id'], {})
return initial_step
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()
def handle_response(self, user_id, response_data):
"""
Handles a user's response, saves the data, and returns the next action.
"""
state = self.get_conversation_state(user_id)
if not state:
return {"status": "error", "message": "No conversation state found."}
flow = self.get_flow(state['flow_id'])
if not flow:
return {"status": "error", "message": f"Flow '{state['flow_id']}' not found."}
current_step = next((step for step in flow['steps'] if step['step_id'] == state['current_step_id']), None)
if not current_step:
self.end_flow(user_id)
return {"status": "error", "message": "Current step not found in flow."}
# Save the user's response using the 'variable' key from the step definition
variable_name = current_step.get('variable')
if variable_name:
state['collected_data'][variable_name] = response_data
else:
# Fallback for steps without a 'variable' key
logger.warning(f"Step {current_step['step_id']} in flow {flow['id']} has no 'variable' defined. Saving with default key.")
state['collected_data'][f"step_{current_step['step_id']}_response"] = response_data
# Find the index of the current step to determine the next one robustly
steps = flow['steps']
current_step_index = -1
for i, step in enumerate(steps):
if step['step_id'] == state['current_step_id']:
current_step_index = i
break
# Check if there is a next step in the list
if current_step_index != -1 and current_step_index + 1 < len(steps):
next_step = steps[current_step_index + 1]
self.update_conversation_state(user_id, state['flow_id'], next_step['step_id'], state['collected_data'])
return {"status": "in_progress", "step": next_step}
else:
# This is the last step, so the flow is complete
final_data = state['collected_data']
self.end_flow(user_id)
response = {"status": "complete", "flow_id": flow['id'], "data": final_data}
if flow['id'] == 'client_sales_funnel':
user_query = final_data.get('IDEA_PITCH', '')
sales_pitch = generate_sales_pitch(user_query, final_data)
response['sales_pitch'] = sales_pitch
elif flow['id'] == 'admin_create_nfc_tag':
nfc_tag = generate_nfc_tag(final_data)
response['nfc_tag'] = nfc_tag
return response
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()

71
bot/modules/identity.py Normal file
View File

@@ -0,0 +1,71 @@
# bot/modules/identity.py
# Este script maneja los roles y permisos de los usuarios.
import logging
from bot.db import get_db_connection
from bot.config import ADMIN_ID
logger = logging.getLogger(__name__)
def add_user(telegram_id, role, name=None, employee_id=None, branch=None):
"""
Añade un nuevo usuario o actualiza el rol de uno existente.
"""
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO users (telegram_id, role, name, employee_id, branch)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(telegram_id) DO UPDATE SET
role = excluded.role,
name = excluded.name,
employee_id = excluded.employee_id,
branch = excluded.branch
""", (telegram_id, role, name, employee_id, branch))
conn.commit()
logger.info(f"Usuario {telegram_id} añadido/actualizado con el rol {role}.")
return True
except Exception as e:
logger.error(f"Error al añadir/actualizar usuario {telegram_id}: {e}")
return False
finally:
if conn:
conn.close()
def get_user_role(telegram_id):
"""
Determina el rol de un usuario.
Roles: 'admin', 'crew', 'client'.
"""
# El admin principal se define en el .env para el primer arranque
if str(telegram_id) == ADMIN_ID:
return 'admin'
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT role FROM users WHERE telegram_id = ?", (telegram_id,))
user = cursor.fetchone()
if user:
logger.debug(f"Rol encontrado para {telegram_id}: {user['role']}")
return user['role']
else:
# Si no está en la DB, es un cliente nuevo
logger.debug(f"No se encontró rol para {telegram_id}, asignando 'client'.")
return 'client'
except Exception as e:
logger.error(f"Error al obtener el rol para {telegram_id}: {e}")
return 'client' # Fallback seguro
finally:
if conn:
conn.close()
def is_admin(telegram_id):
"""Verifica si un usuario es administrador."""
return get_user_role(telegram_id) == 'admin'
def is_crew(telegram_id):
"""Verifica si un usuario es del equipo (crew) o administrador."""
return get_user_role(telegram_id) in ['admin', 'crew']

56
bot/modules/llm_engine.py Normal file
View File

@@ -0,0 +1,56 @@
# bot/modules/llm_engine.py
# Este script se encarga de la comunicación con la inteligencia artificial de OpenAI.
import openai
from bot.config import OPENAI_API_KEY, OPENAI_MODEL
def get_smart_response(prompt):
"""
Genera una respuesta inteligente usando la API de OpenAI.
Parámetros:
- prompt: El texto o pregunta que le enviamos a la IA.
"""
# Verificamos que tengamos la llave de la API configurada
if not OPENAI_API_KEY:
return "Error: La llave de la API de OpenAI no está configurada."
try:
# Creamos el cliente de OpenAI
client = openai.OpenAI(api_key=OPENAI_API_KEY)
# Solicitamos una respuesta al modelo configurado
response = client.chat.completions.create(
model=OPENAI_MODEL,
messages=[
{"role": "system", "content": "Eres un asistente útil."},
{"role": "user", "content": prompt},
],
)
# Devolvemos el contenido de la respuesta limpia (sin espacios extras)
return response.choices[0].message.content.strip()
except Exception as e:
# Si algo sale mal, devolvemos el error
return f"Ocurrió un error al comunicarse con OpenAI: {e}"
def transcribe_audio(audio_file_path):
"""
Transcribes an audio file using OpenAI's Whisper model.
Parameters:
- audio_file_path: The path to the audio file.
"""
if not OPENAI_API_KEY:
return "Error: OPENAI_API_KEY is not configured."
try:
client = openai.OpenAI(api_key=OPENAI_API_KEY)
with open(audio_file_path, "rb") as audio_file:
transcript = client.audio.transcriptions.create(
model="whisper-1",
file=audio_file
)
return transcript.text
except Exception as e:
return f"Error during audio transcription: {e}"

25
bot/modules/nfc_tag.py Normal file
View File

@@ -0,0 +1,25 @@
# bot/modules/nfc_tag.py
# This module contains the logic for generating NFC tags.
import base64
import json
import logging
logger = logging.getLogger(__name__)
def generate_nfc_tag(collected_data):
"""
Generates a Base64 encoded string from the collected data.
"""
tag_data = {
"name": collected_data.get("EMPLOYEE_NAME"),
"num_emp": collected_data.get("EMPLOYEE_ID"),
"sucursal": collected_data.get("BRANCH"),
"telegram_id": collected_data.get("TELEGRAM_ID"),
}
json_string = json.dumps(tag_data)
base64_bytes = base64.b64encode(json_string.encode("utf-8"))
base64_string = base64_bytes.decode("utf-8")
return f"¡Gracias! Aquí está tu tag en formato Base64:\n\n`{base64_string}`"

65
bot/modules/onboarding.py Normal file
View File

@@ -0,0 +1,65 @@
# bot/modules/onboarding.py
# Este módulo maneja la primera interacción con el usuario (el comando /start).
# Se encarga de mostrar un menú diferente según quién sea el usuario (admin, crew o cliente).
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
def get_admin_menu(flow_engine):
"""Crea el menú de botones principal para los Administradores."""
keyboard = [
[InlineKeyboardButton("👑 Revisar Pendientes", callback_data='view_pending')],
[InlineKeyboardButton("📅 Agenda", callback_data='view_agenda')],
]
# Dynamic buttons from flows
if flow_engine:
for flow in flow_engine.flows:
if flow.get("role") == "admin" and "trigger_button" in flow and "name" in flow:
button = InlineKeyboardButton(flow["name"], callback_data=flow["trigger_button"])
keyboard.append([button])
keyboard.append([InlineKeyboardButton("▶️ Más opciones", callback_data='admin_menu')])
return InlineKeyboardMarkup(keyboard)
def get_admin_secondary_menu():
"""Crea el menú secundario para Administradores."""
text = "Aquí tienes más opciones de administración:"
keyboard = [
[InlineKeyboardButton("📋 Gestionar Tareas (Vikunja)", callback_data='manage_vikunja')],
[InlineKeyboardButton("📊 Estado del sistema", callback_data='view_system_status')],
[InlineKeyboardButton("👥 Gestionar Usuarios", callback_data='manage_users')],
]
reply_markup = InlineKeyboardMarkup(keyboard)
return text, reply_markup
def get_crew_menu():
"""Crea el menú de botones para los Miembros del Equipo."""
keyboard = [
[InlineKeyboardButton("🕒 Proponer actividad", callback_data='propose_activity')],
[InlineKeyboardButton("📄 Ver estatus de solicitudes", callback_data='view_requests_status')],
]
return InlineKeyboardMarkup(keyboard)
def get_client_menu():
"""Crea el menú de botones para los Clientes externos."""
keyboard = [
[InlineKeyboardButton("🗓️ Agendar una cita", callback_data='schedule_appointment')],
[InlineKeyboardButton(" Información de servicios", callback_data='get_service_info')],
]
return InlineKeyboardMarkup(keyboard)
def handle_start(user_role, flow_engine=None):
"""
Decide qué mensaje y qué menú mostrar según el rol del usuario.
"""
welcome_message = "Hola, soy Talía. ¿En qué puedo ayudarte hoy?"
if user_role == "admin":
menu = get_admin_menu(flow_engine)
elif user_role == "crew":
menu = get_crew_menu()
else:
menu = get_client_menu()
return welcome_message, menu

121
bot/modules/printer.py Normal file
View File

@@ -0,0 +1,121 @@
# bot/modules/printer.py
# This module will contain the SMTP/IMAP loop for the remote printing service.
import smtplib
import imaplib
import email
import logging
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
from bot.config import (
SMTP_SERVER,
SMTP_PORT,
SMTP_USER,
SMTP_PASS,
IMAP_SERVER,
IMAP_USER,
IMAP_PASS,
PRINTER_EMAIL,
)
from bot.modules.identity import is_admin
logger = logging.getLogger(__name__)
async def send_file_to_printer(file_path: str, user_id: int, file_name: str):
"""
Sends a file to the printer via email.
"""
if not is_admin(user_id):
return "No tienes permiso para usar este comando."
if not all([SMTP_SERVER, SMTP_PORT, SMTP_USER, SMTP_PASS, PRINTER_EMAIL]):
logger.error("Faltan una o más variables de entorno SMTP o PRINTER_EMAIL.")
return "El servicio de impresión no está configurado correctamente."
try:
msg = MIMEMultipart()
msg["From"] = SMTP_USER
msg["To"] = PRINTER_EMAIL
msg["Subject"] = f"Print Job from {user_id}: {file_name}"
body = f"Nuevo trabajo de impresión enviado por el usuario {user_id}.\nNombre del archivo: {file_name}"
msg.attach(MIMEText(body, "plain"))
with open(file_path, "rb") as attachment:
part = MIMEBase("application", "octet-stream")
part.set_payload(attachment.read())
encoders.encode_base64(part)
part.add_header(
"Content-Disposition",
f"attachment; filename= {file_name}",
)
msg.attach(part)
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT)
server.login(SMTP_USER, SMTP_PASS)
text = msg.as_string()
server.sendmail(SMTP_USER, PRINTER_EMAIL, text)
server.quit()
logger.info(f"Archivo {file_name} enviado a la impresora ({PRINTER_EMAIL}) por el usuario {user_id}.")
return f"Tu archivo '{file_name}' ha sido enviado a la impresora. Recibirás una notificación cuando el estado del trabajo cambie."
except Exception as e:
logger.error(f"Error al enviar el correo de impresión: {e}")
return "Ocurrió un error al enviar el archivo a la impresora. Por favor, inténtalo de nuevo más tarde."
async def check_print_status(user_id: int):
"""
Checks the status of print jobs by reading the inbox.
"""
if not is_admin(user_id):
return "No tienes permiso para usar este comando."
if not all([IMAP_SERVER, IMAP_USER, IMAP_PASS]):
logger.error("Faltan una o más variables de entorno IMAP.")
return "El servicio de monitoreo de impresión no está configurado correctamente."
try:
mail = imaplib.IMAP4_SSL(IMAP_SERVER)
mail.login(IMAP_USER, IMAP_PASS)
mail.select("inbox")
status, messages = mail.search(None, "UNSEEN")
if status != "OK":
return "No se pudieron buscar los correos."
email_ids = messages[0].split()
if not email_ids:
return "No hay actualizaciones de estado de impresión."
statuses = []
for e_id in email_ids:
_, msg_data = mail.fetch(e_id, "(RFC822)")
for response_part in msg_data:
if isinstance(response_part, tuple):
msg = email.message_from_bytes(response_part[1])
subject = msg["subject"].lower()
if "completed" in subject:
statuses.append(f"Trabajo de impresión completado: {msg['subject']}")
elif "failed" in subject:
statuses.append(f"Trabajo de impresión fallido: {msg['subject']}")
elif "received" in subject:
statuses.append(f"Trabajo de impresión recibido: {msg['subject']}")
else:
statuses.append(f"Nuevo correo: {msg['subject']}")
mail.logout()
if not statuses:
return "No se encontraron actualizaciones de estado relevantes."
return "\n".join(statuses)
except Exception as e:
logger.error(f"Error al revisar el estado de la impresión: {e}")
return "Ocurrió un error al revisar el estado de la impresión."

73
bot/modules/sales_rag.py Normal file
View File

@@ -0,0 +1,73 @@
# bot/modules/sales_rag.py
# This module will contain the sales RAG flow for new clients.
import json
import logging
from bot.modules.llm_engine import get_smart_response
logger = logging.getLogger(__name__)
def load_services_data():
"""Loads the services data from the JSON file."""
try:
with open("bot/data/services.json", "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
logger.error("El archivo services.json no fue encontrado.")
return []
except json.JSONDecodeError:
logger.error("Error al decodificar el archivo services.json.")
return []
def find_relevant_services(user_query, services):
"""
Finds relevant services based on the user's query.
A simple keyword matching approach is used here.
"""
query = user_query.lower()
relevant_services = []
for service in services:
for keyword in service.get("keywords", []):
if keyword in query:
relevant_services.append(service)
break # Avoid adding the same service multiple times
return relevant_services
def generate_sales_pitch(user_query, collected_data):
"""
Generates a personalized sales pitch using the RAG approach.
"""
services = load_services_data()
relevant_services = find_relevant_services(user_query, services)
if not relevant_services:
logger.warning(f"No se encontraron servicios relevantes para la consulta: '{user_query}'. No se generará respuesta.")
return ("Gracias por tu interés. Sin embargo, con la información proporcionada no he podido identificar "
"servicios específicos que se ajusten a tu necesidad. ¿Podrías describir tu proyecto con otras palabras "
"o dar más detalles sobre lo que buscas?")
context_str = "Según tus necesidades, aquí tienes algunos de nuestros servicios y ejemplos de lo que podemos hacer:\n"
for service in relevant_services:
context_str += f"\n**Servicio:** {service['service_name']}\n"
context_str += f"*Descripción:* {service['description']}\n"
if "work_examples" in service:
context_str += "*Ejemplos de trabajo:*\n"
for example in service["work_examples"]:
context_str += f" - {example}\n"
prompt = (
f"Eres Talía, una asistente de ventas experta y amigable. Un cliente potencial llamado "
f"{collected_data.get('CLIENT_NAME', 'cliente')} del sector "
f"'{collected_data.get('CLIENT_INDUSTRY', 'no especificado')}' "
f"ha descrito su proyecto o necesidad de la siguiente manera: '{user_query}'.\n\n"
"A continuación, se presenta información sobre nuestros servicios que podría ser relevante para ellos:\n"
f"{context_str}\n\n"
"**Tu tarea es generar una respuesta personalizada que:**\n"
"1. Demuestre que has comprendido su necesidad específica.\n"
"2. Conecte de manera clara y directa su proyecto con nuestros servicios, utilizando los ejemplos de trabajo para ilustrar cómo podemos ayudar.\n"
"3. Mantenga un tono profesional, pero cercano y proactivo.\n"
"4. Finalice con una llamada a la acción clara, sugiriendo agendar una breve llamada para explorar la idea más a fondo.\n"
"No te limites a listar los servicios; explica *cómo* se aplican a su caso."
)
return get_smart_response(prompt)

206
bot/modules/vikunja.py Normal file
View File

@@ -0,0 +1,206 @@
# app/modules/vikunja.py
# Este módulo maneja la integración con Vikunja para la gestión de tareas.
import requests
import logging
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (
ConversationHandler,
CommandHandler,
CallbackQueryHandler,
MessageHandler,
filters,
ContextTypes,
)
from bot.config import VIKUNJA_API_URL, VIKUNJA_API_TOKEN
from bot.modules.identity import is_admin
# Configuración del logger
logger = logging.getLogger(__name__)
# Definición de los estados de la conversación para añadir y editar tareas
SELECTING_ACTION, ADDING_TASK, SELECTING_TASK_TO_EDIT, EDITING_TASK = range(4)
def get_vikunja_headers():
"""Devuelve los headers necesarios para la API de Vikunja."""
return {
"Authorization": f"Bearer {VIKUNJA_API_TOKEN}",
"Content-Type": "application/json",
}
def get_projects_list():
"""Returns a list of projects from Vikunja."""
if not VIKUNJA_API_TOKEN:
return []
try:
response = requests.get(f"{VIKUNJA_API_URL}/projects", headers=get_vikunja_headers())
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error fetching projects: {e}")
return []
def get_tasks_list(project_id=1):
"""Returns a list of tasks for a project."""
if not VIKUNJA_API_TOKEN:
return []
try:
response = requests.get(f"{VIKUNJA_API_URL}/projects/{project_id}/tasks", headers=get_vikunja_headers())
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error fetching tasks: {e}")
return []
def get_tasks():
"""
Obtiene y formatea la lista de tareas de Vikunja.
Esta función es síncrona y devuelve un string.
"""
if not VIKUNJA_API_TOKEN:
return "Error: VIKUNJA_API_TOKEN no configurado."
try:
response = requests.get(f"{VIKUNJA_API_URL}/projects/1/tasks", headers=get_vikunja_headers())
response.raise_for_status()
tasks = response.json()
if not tasks:
return "No tienes tareas pendientes en Vikunja."
text = "📋 *Tus Tareas en Vikunja*\n\n"
for task in sorted(tasks, key=lambda t: t.get('id', 0))[:10]:
status = "" if task.get('done') else ""
text += f"{status} `{task.get('id')}`: *{task.get('title')}*\n"
return text
except Exception as e:
logger.error(f"Error al obtener tareas de Vikunja: {e}")
return f"Error al conectar con Vikunja: {e}"
async def vikunja_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Muestra el menú principal de acciones de Vikunja."""
query = update.callback_query
await query.answer()
keyboard = [
[InlineKeyboardButton("Añadir Tarea", callback_data='add_task')],
[InlineKeyboardButton("Editar Tarea", callback_data='edit_task_start')],
[InlineKeyboardButton("Volver", callback_data='cancel')],
]
reply_markup = InlineKeyboardMarkup(keyboard)
tasks_list = get_tasks()
await query.edit_message_text(text=f"{tasks_list}\n\nSelecciona una acción:", reply_markup=reply_markup, parse_mode='Markdown')
return SELECTING_ACTION
async def request_task_title(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Solicita al usuario el título de la nueva tarea."""
query = update.callback_query
await query.answer()
await query.edit_message_text("Por favor, introduce el título de la nueva tarea:")
return ADDING_TASK
async def add_task(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Añade una nueva tarea a Vikunja."""
task_title = update.message.text
try:
data = {"title": task_title, "project_id": 1}
response = requests.post(f"{VIKUNJA_API_URL}/tasks", headers=get_vikunja_headers(), json=data)
response.raise_for_status()
await update.message.reply_text(f"✅ Tarea añadida: *{task_title}*", parse_mode='Markdown')
except Exception as e:
logger.error(f"Error al añadir tarea a Vikunja: {e}")
await update.message.reply_text(f"Error al añadir tarea: {e}")
return ConversationHandler.END
async def select_task_to_edit(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Muestra los botones para seleccionar qué tarea editar."""
query = update.callback_query
await query.answer()
try:
response = requests.get(f"{VIKUNJA_API_URL}/projects/1/tasks", headers=get_vikunja_headers())
response.raise_for_status()
tasks = [task for task in response.json() if not task.get('done')]
if not tasks:
await query.edit_message_text("No hay tareas pendientes para editar.")
return ConversationHandler.END
keyboard = []
for task in sorted(tasks, key=lambda t: t.get('id', 0))[:10]:
keyboard.append([InlineKeyboardButton(
f"{task.get('id')}: {task.get('title')}",
callback_data=f"edit_task:{task.get('id')}"
)])
keyboard.append([InlineKeyboardButton("Cancelar", callback_data='cancel')])
reply_markup = InlineKeyboardMarkup(keyboard)
await query.edit_message_text("Selecciona la tarea que quieres editar:", reply_markup=reply_markup)
return SELECTING_TASK_TO_EDIT
except Exception as e:
logger.error(f"Error al obtener tareas para editar: {e}")
await query.edit_message_text("Error al obtener la lista de tareas.")
return ConversationHandler.END
async def request_new_task_title(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Solicita el nuevo título para la tarea seleccionada."""
query = update.callback_query
await query.answer()
task_id = query.data.split(':')[1]
context.user_data['task_id_to_edit'] = task_id
await query.edit_message_text(f"Introduce el nuevo título para la tarea `{task_id}`:", parse_mode='Markdown')
return EDITING_TASK
async def edit_task(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Actualiza el título de una tarea en Vikunja."""
new_title = update.message.text
task_id = context.user_data.get('task_id_to_edit')
if not task_id:
await update.message.reply_text("Error: No se encontró el ID de la tarea a editar.")
return ConversationHandler.END
try:
data = {"title": new_title}
response = requests.put(f"{VIKUNJA_API_URL}/tasks/{task_id}", headers=get_vikunja_headers(), json=data)
response.raise_for_status()
await update.message.reply_text(f"✅ Tarea `{task_id}` actualizada a *{new_title}*", parse_mode='Markdown')
except Exception as e:
logger.error(f"Error al editar la tarea {task_id}: {e}")
await update.message.reply_text("Error al actualizar la tarea.")
finally:
del context.user_data['task_id_to_edit']
return ConversationHandler.END
async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Cancela la conversación actual."""
query = update.callback_query
await query.answer()
await query.edit_message_text("Operación cancelada.")
return ConversationHandler.END
def vikunja_conv_handler():
"""Crea el ConversationHandler para el flujo de Vikunja."""
return ConversationHandler(
entry_points=[CallbackQueryHandler(vikunja_menu, pattern='^manage_vikunja$')],
states={
SELECTING_ACTION: [
CallbackQueryHandler(request_task_title, pattern='^add_task$'),
CallbackQueryHandler(select_task_to_edit, pattern='^edit_task_start$'),
CallbackQueryHandler(cancel, pattern='^cancel$'),
],
ADDING_TASK: [MessageHandler(filters.TEXT & ~filters.COMMAND, add_task)],
SELECTING_TASK_TO_EDIT: [
CallbackQueryHandler(request_new_task_title, pattern=r'^edit_task:\d+$'),
CallbackQueryHandler(cancel, pattern='^cancel$'),
],
EDITING_TASK: [MessageHandler(filters.TEXT & ~filters.COMMAND, edit_task)],
},
fallbacks=[CommandHandler('cancel', cancel)],
)

76
bot/scheduler.py Normal file
View File

@@ -0,0 +1,76 @@
# app/scheduler.py
# Este script se encarga de programar tareas automáticas, como el resumen diario.
# app/scheduler.py
# Este script se encarga de programar tareas automáticas, como el resumen diario.
import logging
from datetime import time
from telegram.ext import ContextTypes
import pytz
from bot.config import ADMIN_ID, TIMEZONE, DAILY_SUMMARY_TIME
from bot.modules.agenda import get_agenda
# Configuramos el registro de eventos (logging) para ver qué pasa en la consola
logger = logging.getLogger(__name__)
async def send_daily_summary(context: ContextTypes.DEFAULT_TYPE) -> None:
"""
Función que envía el resumen diario al dueño del bot.
Se ejecuta automáticamente según lo programado.
"""
job = context.job
chat_id = job.chat_id
logger.info(f"Ejecutando tarea de resumen diario para el chat_id: {chat_id}")
try:
# Obtenemos la agenda del día
agenda_text = get_agenda()
# Preparamos el mensaje
summary_text = f"🔔 *Resumen Diario - Buen día, Marco!*\n\n{agenda_text}"
# Enviamos el mensaje por Telegram
await context.bot.send_message(
chat_id=chat_id,
text=summary_text,
parse_mode='Markdown'
)
logger.info(f"Resumen diario enviado con éxito a {chat_id}")
except Exception as e:
# Si hay un error, lo registramos
logger.error(f"Error al enviar el resumen diario a {chat_id}: {e}")
def schedule_daily_summary(application) -> None:
"""
Programa la tarea del resumen diario para que ocurra todos los días.
"""
# Si no hay un ID de dueño configurado, no programamos nada
if not ADMIN_ID:
logger.warning("ADMIN_ID no configurado. No se programará el resumen diario.")
return
job_queue = application.job_queue
# Configuramos la zona horaria (ej. America/Mexico_City)
tz = pytz.timezone(TIMEZONE)
# Obtenemos la hora y minutos desde la configuración (ej. "07:00")
try:
hour, minute = map(int, DAILY_SUMMARY_TIME.split(':'))
except ValueError:
logger.error(f"Formato de DAILY_SUMMARY_TIME inválido: {DAILY_SUMMARY_TIME}. Usando 07:00 por defecto.")
hour, minute = 7, 0
# Programamos la tarea para que corra todos los días a la hora configurada
scheduled_time = time(hour=hour, minute=minute, tzinfo=tz)
job_queue.run_daily(
send_daily_summary,
time=scheduled_time,
chat_id=int(ADMIN_ID),
name="daily_summary"
)
logger.info(f"Resumen diario programado para {ADMIN_ID} a las {scheduled_time} ({TIMEZONE})")

33
bot/webhook_client.py Normal file
View File

@@ -0,0 +1,33 @@
# app/webhook_client.py
# Este script se encarga de enviar datos a servicios externos usando "webhooks".
# En este caso, se comunica con n8n.
import requests
from bot.config import N8N_WEBHOOK_URL, N8N_TEST_WEBHOOK_URL
def send_webhook(event_data):
"""
Envía datos de un evento al servicio n8n.
Usa el webhook normal y, si falla o no existe, usa el de test como fallback.
"""
# Intentar con el webhook principal
if N8N_WEBHOOK_URL:
try:
print(f"Intentando enviar a webhook principal: {N8N_WEBHOOK_URL}")
response = requests.post(N8N_WEBHOOK_URL, json=event_data)
response.raise_for_status()
return response.json()
except Exception as e:
print(f"Fallo en webhook principal: {e}")
# Fallback al webhook de test
if N8N_TEST_WEBHOOK_URL:
try:
print(f"Intentando enviar a webhook de fallback (test): {N8N_TEST_WEBHOOK_URL}")
response = requests.post(N8N_TEST_WEBHOOK_URL, json=event_data)
response.raise_for_status()
return response.json()
except Exception as e:
print(f"Fallo en webhook de fallback: {e}")
return None