mirror of
https://github.com/marcogll/talia_bot.git
synced 2026-01-13 13:25:19 +00:00
refactor: Overhaul project structure and role management
This commit implements the first phase of the new architectural vision for the Talia Bot.
Key changes include:
- Renamed the main application directory from `app` to `talia_bot` and updated all associated imports and configurations (`Dockerfile`, tests).
- Replaced the static, `.env`-based permission system with a dynamic, database-driven role management system.
- Introduced a `db.py` module to manage a SQLite database (`users.db`) for user persistence.
- Updated `identity.py` to fetch roles ('admin', 'crew', 'client') from the database.
- Rewrote the `README.md` and `.env.example` to align with the new project specification.
- Refactored the LLM module into the new `modules` structure.
This commit is contained in:
48
talia_bot/config.py
Normal file
48
talia_bot/config.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# talia_bot/config.py
|
||||
# Este archivo se encarga de cargar todas las variables de entorno y configuraciones del bot.
|
||||
# Las variables de entorno son valores que se guardan fuera del código por seguridad (como tokens y llaves API).
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from pathlib import Path
|
||||
|
||||
# Cargar variables de entorno desde el archivo .env en la raíz del proyecto
|
||||
env_path = Path(__file__).parent.parent / '.env'
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
# Token del bot de Telegram (obtenido de @BotFather)
|
||||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
|
||||
# ID de chat del dueño del bot (para recibir notificaciones importantes)
|
||||
ADMIN_ID = os.getenv("ADMIN_ID")
|
||||
|
||||
# Ruta al archivo de credenciales de la cuenta de servicio de Google
|
||||
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)
|
||||
|
||||
# ID del calendario de Google que usará el bot
|
||||
CALENDAR_ID = os.getenv("CALENDAR_ID")
|
||||
|
||||
# URL del webhook de n8n para enviar datos a otros servicios
|
||||
N8N_WEBHOOK_URL = os.getenv("N8N_WEBHOOK_URL")
|
||||
N8N_TEST_WEBHOOK_URL = os.getenv("N8N_TEST_WEBHOOK_URL")
|
||||
|
||||
# Configuración de Vikunja
|
||||
VIKUNJA_API_URL = os.getenv("VIKUNJA_API_URL", "https://tasks.soul23.cloud/api/v1")
|
||||
VIKUNJA_API_TOKEN = os.getenv("VIKUNJA_API_TOKEN")
|
||||
|
||||
# Llave de la API de OpenAI para usar modelos de lenguaje (como GPT)
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||
|
||||
# Modelo de OpenAI a utilizar (ej. gpt-3.5-turbo, gpt-4)
|
||||
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-3.5-turbo")
|
||||
|
||||
# Hora del resumen diario (formato HH:MM)
|
||||
DAILY_SUMMARY_TIME = os.getenv("DAILY_SUMMARY_TIME", "07:00")
|
||||
|
||||
# Enlace de Calendly para agendar citas
|
||||
CALENDLY_LINK = os.getenv("CALENDLY_LINK", "https://calendly.com/user/appointment-link")
|
||||
|
||||
# Zona horaria por defecto para el manejo de fechas y horas
|
||||
TIMEZONE = os.getenv("TIMEZONE", "America/Mexico_City")
|
||||
BIN
talia_bot/data/users.db
Normal file
BIN
talia_bot/data/users.db
Normal file
Binary file not shown.
48
talia_bot/db.py
Normal file
48
talia_bot/db.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# talia_bot/db.py
|
||||
# This module will handle the database connection and operations.
|
||||
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
DATABASE_FILE = "talia_bot/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."""
|
||||
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
|
||||
)
|
||||
""")
|
||||
|
||||
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.")
|
||||
157
talia_bot/main.py
Normal file
157
talia_bot/main.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# talia_bot/main.py
|
||||
# Este es el archivo principal del bot. Aquí se inicia todo y se configuran los comandos.
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from telegram import Update
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
CommandHandler,
|
||||
CallbackQueryHandler,
|
||||
ConversationHandler,
|
||||
MessageHandler,
|
||||
ContextTypes,
|
||||
filters,
|
||||
)
|
||||
|
||||
# Importamos las configuraciones y herramientas que creamos en otros archivos
|
||||
from talia_bot.config import TELEGRAM_BOT_TOKEN
|
||||
from talia_bot.modules.identity import get_user_role
|
||||
from talia_bot.modules.onboarding import handle_start as onboarding_handle_start
|
||||
from talia_bot.modules.onboarding import get_admin_secondary_menu
|
||||
from talia_bot.modules.agenda import get_agenda
|
||||
from talia_bot.modules.citas import request_appointment
|
||||
from talia_bot.modules.equipo import (
|
||||
propose_activity_start,
|
||||
get_description,
|
||||
get_duration,
|
||||
cancel_proposal,
|
||||
view_requests_status,
|
||||
DESCRIPTION,
|
||||
DURATION,
|
||||
)
|
||||
from talia_bot.modules.aprobaciones import view_pending, handle_approval_action
|
||||
from talia_bot.modules.servicios import get_service_info
|
||||
from talia_bot.modules.admin import get_system_status
|
||||
from talia_bot.modules.debug import print_handler
|
||||
from talia_bot.modules.create_tag import create_tag_conv_handler
|
||||
from talia_bot.modules.vikunja import vikunja_conv_handler
|
||||
from talia_bot.db import setup_database
|
||||
|
||||
from talia_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 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
|
||||
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)
|
||||
|
||||
# Respondemos al usuario
|
||||
await update.message.reply_text(response_text, reply_markup=reply_markup)
|
||||
|
||||
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,
|
||||
'get_service_info': get_service_info,
|
||||
'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)
|
||||
elif query.data == 'start_create_tag':
|
||||
response_text = "Para crear un tag, por favor usa el comando /create_tag."
|
||||
else:
|
||||
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')
|
||||
|
||||
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()
|
||||
schedule_daily_summary(application)
|
||||
|
||||
# El orden de los handlers es crucial para que las conversaciones funcionen.
|
||||
application.add_handler(create_tag_conv_handler())
|
||||
application.add_handler(vikunja_conv_handler())
|
||||
|
||||
conv_handler = ConversationHandler(
|
||||
entry_points=[CallbackQueryHandler(propose_activity_start, pattern='^propose_activity$')],
|
||||
states={
|
||||
DESCRIPTION: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_description)],
|
||||
DURATION: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_duration)],
|
||||
},
|
||||
fallbacks=[CommandHandler('cancel', cancel_proposal)],
|
||||
per_message=False
|
||||
)
|
||||
application.add_handler(conv_handler)
|
||||
|
||||
application.add_handler(CommandHandler("start", start))
|
||||
application.add_handler(CommandHandler("print", print_handler))
|
||||
|
||||
application.add_handler(CallbackQueryHandler(button_dispatcher))
|
||||
|
||||
logger.info("Iniciando Talía Bot...")
|
||||
application.run_polling()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
20
talia_bot/modules/admin.py
Normal file
20
talia_bot/modules/admin.py
Normal 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
|
||||
44
talia_bot/modules/agenda.py
Normal file
44
talia_bot/modules/agenda.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# talia_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 talia_bot.modules.calendar import get_events
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_agenda():
|
||||
"""
|
||||
Obtiene y muestra la agenda del usuario para el día actual desde Google Calendar.
|
||||
"""
|
||||
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 desde {start_of_day} hasta {end_of_day}")
|
||||
events = get_events(start_of_day, end_of_day)
|
||||
|
||||
if not events:
|
||||
logger.info("No se encontraron eventos.")
|
||||
return "📅 *Agenda para Hoy*\n\nNo tienes eventos programados para hoy."
|
||||
|
||||
agenda_text = "📅 *Agenda para Hoy*\n\n"
|
||||
for event in events:
|
||||
start = event["start"].get("dateTime", event["start"].get("date"))
|
||||
# Formatear la hora si es posible
|
||||
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"
|
||||
|
||||
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."
|
||||
69
talia_bot/modules/aprobaciones.py
Normal file
69
talia_bot/modules/aprobaciones.py
Normal 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
talia_bot/modules/calendar.py
Normal file
146
talia_bot/modules/calendar.py
Normal 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 config import GOOGLE_SERVICE_ACCOUNT_FILE, 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=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=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=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
talia_bot/modules/citas.py
Normal file
17
talia_bot/modules/citas.py
Normal 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 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
|
||||
121
talia_bot/modules/create_tag.py
Normal file
121
talia_bot/modules/create_tag.py
Normal file
@@ -0,0 +1,121 @@
|
||||
# app/modules/create_tag.py
|
||||
# Este módulo permite crear un "tag" (etiqueta) con información del empleado.
|
||||
# Usa un ConversationHandler para hacer una serie de preguntas al usuario.
|
||||
# Al final, genera un código en Base64 que contiene toda la información en formato JSON.
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from telegram import Update
|
||||
from telegram.ext import (
|
||||
ContextTypes,
|
||||
ConversationHandler,
|
||||
CommandHandler,
|
||||
MessageHandler,
|
||||
filters,
|
||||
)
|
||||
|
||||
# Configuramos los logs para este archivo
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Definimos los estados de la conversación.
|
||||
# Cada número representa un paso en el proceso de preguntas.
|
||||
NAME, NUM_EMP, SUCURSAL, TELEGRAM_ID = range(4)
|
||||
|
||||
async def create_tag_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""
|
||||
Inicia el proceso cuando el usuario escribe /create_tag.
|
||||
Pide el primer dato: el nombre.
|
||||
"""
|
||||
await update.message.reply_text("Vamos a crear un nuevo tag. Por favor, dime el nombre:")
|
||||
# Devolvemos el siguiente estado: NAME
|
||||
return NAME
|
||||
|
||||
async def get_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""
|
||||
Guarda el nombre y pide el número de empleado.
|
||||
"""
|
||||
context.user_data['name'] = update.message.text
|
||||
await update.message.reply_text("Gracias. Ahora, por favor, dime el número de empleado:")
|
||||
# Devolvemos el siguiente estado: NUM_EMP
|
||||
return NUM_EMP
|
||||
|
||||
async def get_num_emp(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""
|
||||
Guarda el número de empleado y pide la sucursal.
|
||||
"""
|
||||
context.user_data['num_emp'] = update.message.text
|
||||
await update.message.reply_text("Entendido. Ahora, por favor, dime la sucursal:")
|
||||
# Devolvemos el siguiente estado: SUCURSAL
|
||||
return SUCURSAL
|
||||
|
||||
async def get_sucursal(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""
|
||||
Guarda la sucursal y pide el ID de Telegram.
|
||||
"""
|
||||
context.user_data['sucursal'] = update.message.text
|
||||
await update.message.reply_text("Perfecto. Finalmente, por favor, dime el ID de Telegram:")
|
||||
# Devolvemos el siguiente estado: TELEGRAM_ID
|
||||
return TELEGRAM_ID
|
||||
|
||||
async def get_telegram_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""
|
||||
Guarda el ID de Telegram, junta todos los datos y genera el código Base64.
|
||||
"""
|
||||
context.user_data['telegram_id'] = update.message.text
|
||||
|
||||
# Creamos un diccionario (como una caja con etiquetas) con todos los datos
|
||||
tag_data = {
|
||||
"name": context.user_data.get('name'),
|
||||
"num_emp": context.user_data.get('num_emp'),
|
||||
"sucursal": context.user_data.get('sucursal'),
|
||||
"telegram_id": context.user_data.get('telegram_id'),
|
||||
}
|
||||
|
||||
# Convertimos el diccionario a una cadena de texto en formato JSON
|
||||
json_string = json.dumps(tag_data)
|
||||
|
||||
# Convertimos esa cadena a Base64 (un formato que se puede guardar en tags NFC)
|
||||
# 1. Codificamos a bytes (utf-8)
|
||||
# 2. Codificamos esos bytes a base64
|
||||
# 3. Convertimos de vuelta a texto para mostrarlo
|
||||
base64_bytes = base64.b64encode(json_string.encode('utf-8'))
|
||||
base64_string = base64_bytes.decode('utf-8')
|
||||
|
||||
await update.message.reply_text(f"¡Gracias! Aquí está tu tag en formato Base64:\n\n`{base64_string}`", parse_mode='Markdown')
|
||||
|
||||
# Limpiamos los datos temporales del usuario
|
||||
context.user_data.clear()
|
||||
|
||||
# Terminamos la conversación
|
||||
return ConversationHandler.END
|
||||
|
||||
async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""
|
||||
Cancela el proceso si el usuario escribe /cancel.
|
||||
"""
|
||||
await update.message.reply_text("Creación de tag cancelada.")
|
||||
context.user_data.clear()
|
||||
return ConversationHandler.END
|
||||
|
||||
def create_tag_conv_handler():
|
||||
"""
|
||||
Configura el manejador de la conversación (el flujo de preguntas).
|
||||
"""
|
||||
return ConversationHandler(
|
||||
# Punto de entrada: el comando /create_tag
|
||||
entry_points=[CommandHandler('create_tag', create_tag_start)],
|
||||
|
||||
# Mapa de estados: qué función responde a cada paso
|
||||
states={
|
||||
NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_name)],
|
||||
NUM_EMP: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_num_emp)],
|
||||
SUCURSAL: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_sucursal)],
|
||||
TELEGRAM_ID: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_telegram_id)],
|
||||
},
|
||||
|
||||
# Si algo falla o el usuario cancela
|
||||
fallbacks=[CommandHandler('cancel', cancel)],
|
||||
|
||||
per_message=False
|
||||
)
|
||||
30
talia_bot/modules/debug.py
Normal file
30
talia_bot/modules/debug.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# talia_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 talia_bot.modules.identity import is_admin
|
||||
from talia_bot.config import TIMEZONE, 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"ID de Calendario: `{CALENDAR_ID}`\n"
|
||||
f"URL Webhook n8n: `{N8N_WEBHOOK_URL}`\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.")
|
||||
79
talia_bot/modules/equipo.py
Normal file
79
talia_bot/modules/equipo.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# 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.
|
||||
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes, ConversationHandler
|
||||
|
||||
# Definimos los estados para la conversación de propuesta de actividad.
|
||||
DESCRIPTION, DURATION = range(2)
|
||||
|
||||
async def propose_activity_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""
|
||||
Inicia el proceso para que un miembro del equipo proponga una actividad.
|
||||
Se activa cuando se pulsa el botón correspondiente.
|
||||
"""
|
||||
await update.callback_query.answer()
|
||||
await update.callback_query.edit_message_text(
|
||||
"Por favor, describe la actividad que quieres proponer."
|
||||
)
|
||||
# Siguiente paso: DESCRIPTION
|
||||
return DESCRIPTION
|
||||
|
||||
async def get_description(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""
|
||||
Guarda la descripción de la actividad y pide la duración.
|
||||
"""
|
||||
context.user_data['activity_description'] = update.message.text
|
||||
await update.message.reply_text(
|
||||
"Entendido. Ahora, por favor, indica la duración estimada en horas (ej. 2, 4.5)."
|
||||
)
|
||||
# Siguiente paso: DURATION
|
||||
return DURATION
|
||||
|
||||
async def get_duration(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""
|
||||
Guarda la duración, confirma la propuesta y termina la conversación.
|
||||
"""
|
||||
try:
|
||||
# Intentamos convertir el texto a un número decimal (float)
|
||||
duration = float(update.message.text)
|
||||
context.user_data['activity_duration'] = duration
|
||||
description = context.user_data.get('activity_description', 'N/A')
|
||||
|
||||
confirmation_text = (
|
||||
f"Gracias. Se ha enviado la siguiente propuesta para aprobación:\n\n"
|
||||
f"📝 *Actividad:* {description}\n"
|
||||
f"⏳ *Duración:* {duration} horas\n\n"
|
||||
"Recibirás una notificación cuando sea revisada."
|
||||
)
|
||||
|
||||
# TODO: Enviar esta propuesta al dueño (por webhook o base de datos).
|
||||
await update.message.reply_text(confirmation_text, parse_mode='Markdown')
|
||||
|
||||
# Limpiamos los datos temporales
|
||||
context.user_data.clear()
|
||||
|
||||
# Terminamos la conversación
|
||||
return ConversationHandler.END
|
||||
except ValueError:
|
||||
# Si el usuario no escribe un número válido, se lo pedimos de nuevo
|
||||
await update.message.reply_text("Por favor, introduce un número válido para la duración en horas.")
|
||||
return DURATION
|
||||
|
||||
async def cancel_proposal(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""
|
||||
Cancela el proceso de propuesta si el usuario escribe /cancel.
|
||||
"""
|
||||
await update.message.reply_text("La propuesta de actividad ha sido cancelada.")
|
||||
context.user_data.clear()
|
||||
return ConversationHandler.END
|
||||
|
||||
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"
|
||||
71
talia_bot/modules/identity.py
Normal file
71
talia_bot/modules/identity.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# talia_bot/modules/identity.py
|
||||
# Este script maneja los roles y permisos de los usuarios.
|
||||
|
||||
import logging
|
||||
from talia_bot.db import get_db_connection
|
||||
from talia_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']
|
||||
34
talia_bot/modules/llm_engine.py
Normal file
34
talia_bot/modules/llm_engine.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# talia_bot/modules/llm_engine.py
|
||||
# Este script se encarga de la comunicación con la inteligencia artificial de OpenAI.
|
||||
|
||||
import openai
|
||||
from talia_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}"
|
||||
57
talia_bot/modules/onboarding.py
Normal file
57
talia_bot/modules/onboarding.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# talia_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():
|
||||
"""Crea el menú de botones principal para los Administradores."""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("👑 Revisar Pendientes", callback_data='view_pending')],
|
||||
[InlineKeyboardButton("📅 Agenda", callback_data='view_agenda')],
|
||||
[InlineKeyboardButton(" NFC", callback_data='start_create_tag')],
|
||||
[InlineKeyboardButton("▶️ Más opciones", callback_data='admin_menu')],
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def get_admin_secondary_menu():
|
||||
"""Crea el menú secundario para Administradores."""
|
||||
text = "Aquí tienes más opciones de administración:"
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📋 Gestionar Tareas (Vikunja)", callback_data='manage_vikunja')],
|
||||
[InlineKeyboardButton("📊 Estado del sistema", callback_data='view_system_status')],
|
||||
[InlineKeyboardButton("👥 Gestionar Usuarios", callback_data='manage_users')],
|
||||
]
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
return text, reply_markup
|
||||
|
||||
def get_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):
|
||||
"""
|
||||
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()
|
||||
elif user_role == "crew":
|
||||
menu = get_crew_menu()
|
||||
else:
|
||||
menu = get_client_menu()
|
||||
|
||||
return welcome_message, menu
|
||||
1
talia_bot/modules/printer.py
Normal file
1
talia_bot/modules/printer.py
Normal file
@@ -0,0 +1 @@
|
||||
# This module will contain the SMTP/IMAP loop for the remote printing service.
|
||||
1
talia_bot/modules/sales_rag.py
Normal file
1
talia_bot/modules/sales_rag.py
Normal file
@@ -0,0 +1 @@
|
||||
# This module will contain the sales RAG flow for new clients.
|
||||
13
talia_bot/modules/servicios.py
Normal file
13
talia_bot/modules/servicios.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# app/modules/servicios.py
|
||||
# Este módulo se encarga de dar información sobre los servicios ofrecidos.
|
||||
# Es un módulo informativo para los clientes.
|
||||
|
||||
def get_service_info():
|
||||
"""
|
||||
Muestra una lista breve de los servicios disponibles.
|
||||
|
||||
Por ahora devuelve un texto fijo. Se podría conectar a una base de datos
|
||||
para que sea más fácil de actualizar.
|
||||
"""
|
||||
# TODO: Obtener detalles de servicios desde una base de datos o archivo de configuración.
|
||||
return "Ofrecemos una variedad de servicios, incluyendo:\n\n- Consultoría Estratégica\n- Desarrollo de Software\n- Talleres de Capacitación\n\n¿Sobre cuál te gustaría saber más?"
|
||||
182
talia_bot/modules/vikunja.py
Normal file
182
talia_bot/modules/vikunja.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# 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 config import VIKUNJA_API_URL, VIKUNJA_API_TOKEN
|
||||
from permissions 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_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)],
|
||||
)
|
||||
73
talia_bot/scheduler.py
Normal file
73
talia_bot/scheduler.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# 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 config import OWNER_CHAT_ID, TIMEZONE, DAILY_SUMMARY_TIME
|
||||
from 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 OWNER_CHAT_ID:
|
||||
logger.warning("OWNER_CHAT_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(OWNER_CHAT_ID),
|
||||
name="daily_summary"
|
||||
)
|
||||
|
||||
logger.info(f"Resumen diario programado para {OWNER_CHAT_ID} a las {scheduled_time} ({TIMEZONE})")
|
||||
33
talia_bot/webhook_client.py
Normal file
33
talia_bot/webhook_client.py
Normal 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 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
|
||||
Reference in New Issue
Block a user