feat: Implement multi-step conversation flows with dynamic UI, persistent state, and improved path handling.

This commit is contained in:
Marco Gallegos
2025-12-21 11:42:08 -06:00
parent c3f4da3687
commit 0c2c9fc524
10 changed files with 231 additions and 21 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# C extensions # C extensions
*.so *.so

View File

@@ -1,7 +1,7 @@
{ {
"id": "admin_project_management", "id": "admin_project_management",
"role": "admin", "role": "admin",
"trigger_button": "🏗️ Ver Proyectos", "trigger_button": "manage_vikunja",
"steps": [ "steps": [
{ {
"step_id": 0, "step_id": 0,

Binary file not shown.

View File

@@ -3,8 +3,9 @@
import sqlite3 import sqlite3
import logging import logging
import os
DATABASE_FILE = "talia_bot/data/users.db" DATABASE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -16,6 +17,7 @@ def get_db_connection():
def setup_database(): def setup_database():
"""Sets up the database tables if they don't exist.""" """Sets up the database tables if they don't exist."""
conn = None
try: try:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
@@ -32,6 +34,16 @@ def setup_database():
) )
""") """)
# 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() conn.commit()
logger.info("Database setup complete. 'users' table is ready.") logger.info("Database setup complete. 'users' table is ready.")
except sqlite3.Error as e: except sqlite3.Error as e:

70
talia_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.

View File

@@ -3,7 +3,7 @@
import logging import logging
import asyncio import asyncio
from telegram import Update from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ( from telegram.ext import (
Application, Application,
CommandHandler, CommandHandler,
@@ -12,6 +12,7 @@ from telegram.ext import (
MessageHandler, MessageHandler,
ContextTypes, ContextTypes,
filters, filters,
TypeHandler,
) )
# Importamos las configuraciones y herramientas que creamos en otros archivos # Importamos las configuraciones y herramientas que creamos en otros archivos
@@ -35,7 +36,7 @@ from talia_bot.modules.servicios import get_service_info
from talia_bot.modules.admin import get_system_status from talia_bot.modules.admin import get_system_status
import os import os
from talia_bot.modules.debug import print_handler from talia_bot.modules.debug import print_handler
from talia_bot.modules.vikunja import vikunja_conv_handler from talia_bot.modules.vikunja import vikunja_conv_handler, get_projects_list, get_tasks_list
from talia_bot.modules.printer import send_file_to_printer, check_print_status from talia_bot.modules.printer import send_file_to_printer, check_print_status
from talia_bot.db import setup_database from talia_bot.db import setup_database
from talia_bot.modules.flow_engine import FlowEngine from talia_bot.modules.flow_engine import FlowEngine
@@ -48,6 +49,52 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def catch_all_handler(update: object, context: ContextTypes.DEFAULT_TYPE):
print("--- CATCH ALL HANDLER ---")
print(update)
async def send_step_message(update: Update, step: dict):
"""Helper to send a message for a flow step, including options if available."""
text = step["question"]
reply_markup = None
options = []
if "options" in step and step["options"]:
options = step["options"]
elif "input_type" in step:
if step["input_type"] == "dynamic_keyboard_vikunja_projects":
projects = get_projects_list()
# Assuming project has 'title' or 'id'
options = [p.get('title', 'Unknown') for p in projects]
elif step["input_type"] == "dynamic_keyboard_vikunja_tasks":
# NOTE: We ideally need the project_id selected in previous step.
# For now, defaulting to project 1 or generic fetch
tasks = get_tasks_list(1)
options = [t.get('title', 'Unknown') for t in tasks]
if options:
keyboard = []
# Create a row for each option or group them
row = []
for option in options:
# Check if option is simple string or object (not implemented in JSONs seen so far)
# Ensure callback_data is not too long
cb_data = str(option)[:64]
row.append(InlineKeyboardButton(str(option), callback_data=cb_data))
if len(row) >= 2:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
reply_markup = InlineKeyboardMarkup(keyboard)
if update.callback_query:
await update.callback_query.edit_message_text(text=text, reply_markup=reply_markup)
else:
await update.message.reply_text(text=text, reply_markup=reply_markup)
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
""" """
Se ejecuta cuando el usuario escribe /start. Se ejecuta cuando el usuario escribe /start.
@@ -73,7 +120,7 @@ async def text_and_voice_handler(update: Update, context: ContextTypes.DEFAULT_T
state = flow_engine.get_conversation_state(user_id) state = flow_engine.get_conversation_state(user_id)
if not state: if not state:
# If there's no active conversation, treat it as a start command # If there's no active conversation, treat it as a start command
await start(update, context) # await start(update, context) # Changed behavior: Don't auto-start, might be annoying
return return
user_response = update.message.text user_response = update.message.text
@@ -85,7 +132,7 @@ async def text_and_voice_handler(update: Update, context: ContextTypes.DEFAULT_T
result = flow_engine.handle_response(user_id, user_response) result = flow_engine.handle_response(user_id, user_response)
if result["status"] == "in_progress": if result["status"] == "in_progress":
await update.message.reply_text(result["step"]["question"]) await send_step_message(update, result["step"])
elif result["status"] == "complete": elif result["status"] == "complete":
if "sales_pitch" in result: if "sales_pitch" in result:
await update.message.reply_text(result["sales_pitch"]) await update.message.reply_text(result["sales_pitch"])
@@ -124,7 +171,17 @@ async def check_print_status_command(update: Update, context: ContextTypes.DEFAU
await update.message.reply_text(response) 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: async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
print("--- BUTTON DISPATCHER CALLED ---")
""" """
Esta función maneja los clics en los botones del menú. Esta función maneja los clics en los botones del menú.
Dependiendo de qué botón se presione, ejecuta una acción diferente. Dependiendo de qué botón se presione, ejecuta una acción diferente.
@@ -169,24 +226,51 @@ async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE)
logger.info(f"Ejecutando acción de aprobación: {query.data}") logger.info(f"Ejecutando acción de aprobación: {query.data}")
response_text = handle_approval_action(query.data) response_text = handle_approval_action(query.data)
else: 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
# Check if the button is a flow trigger # Check if the button is a flow trigger
flow_engine = context.bot_data["flow_engine"] 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) flow_to_start = next((flow for flow in flow_engine.flows if flow.get("trigger_button") == query.data), None)
if flow_to_start: 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"]) initial_step = flow_engine.start_flow(update.effective_user.id, flow_to_start["id"])
if initial_step: if initial_step:
await query.edit_message_text(text=initial_step["question"]) await send_step_message(update, initial_step)
else:
logger.error("No se pudo iniciar el flujo (paso inicial vacío).")
return 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') await query.edit_message_text(text=response_text, reply_markup=reply_markup, parse_mode='Markdown')
def main() -> None: def main() -> None:
@@ -205,9 +289,7 @@ def main() -> None:
schedule_daily_summary(application) schedule_daily_summary(application)
# El orden de los handlers es crucial para que las conversaciones funcionen. # Conversation handler for proposing activities
application.add_handler(vikunja_conv_handler())
conv_handler = ConversationHandler( conv_handler = ConversationHandler(
entry_points=[CallbackQueryHandler(propose_activity_start, pattern='^propose_activity$')], entry_points=[CallbackQueryHandler(propose_activity_start, pattern='^propose_activity$')],
states={ states={
@@ -219,7 +301,11 @@ def main() -> None:
) )
application.add_handler(conv_handler) application.add_handler(conv_handler)
# El orden de los handlers es crucial para que las conversaciones funcionen.
# application.add_handler(vikunja_conv_handler())
application.add_handler(CommandHandler("start", start)) application.add_handler(CommandHandler("start", start))
application.add_handler(CommandHandler("reset", reset_conversation)) # Added reset command
application.add_handler(CommandHandler("print", print_handler)) application.add_handler(CommandHandler("print", print_handler))
application.add_handler(CommandHandler("check_print_status", check_print_status_command)) application.add_handler(CommandHandler("check_print_status", check_print_status_command))
@@ -229,6 +315,8 @@ def main() -> None:
application.add_handler(CallbackQueryHandler(button_dispatcher)) application.add_handler(CallbackQueryHandler(button_dispatcher))
application.add_handler(TypeHandler(object, catch_all_handler))
logger.info("Iniciando Talía Bot...") logger.info("Iniciando Talía Bot...")
application.run_polling() application.run_polling()

View File

@@ -9,6 +9,7 @@ from telegram.ext import ContextTypes, ConversationHandler
DESCRIPTION, DURATION = range(2) DESCRIPTION, DURATION = range(2)
async def propose_activity_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def propose_activity_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
print("--- PROPOSE ACTIVITY START CALLED ---")
""" """
Inicia el proceso para que un miembro del equipo proponga una actividad. Inicia el proceso para que un miembro del equipo proponga una actividad.
Se activa cuando se pulsa el botón correspondiente. Se activa cuando se pulsa el botón correspondiente.

View File

@@ -14,7 +14,10 @@ class FlowEngine:
def _load_flows(self): def _load_flows(self):
"""Loads all individual flow JSON files from the flows directory.""" """Loads all individual flow JSON files from the flows directory."""
flows_dir = 'talia_bot/data/flows' # flows_dir = 'talia_bot/data/flows' # OLD
base_dir = os.path.dirname(os.path.abspath(__file__))
flows_dir = os.path.join(base_dir, '..', 'data', 'flows')
loaded_flows = [] loaded_flows = []
try: try:
if not os.path.exists(flows_dir): if not os.path.exists(flows_dir):

View File

@@ -29,6 +29,30 @@ def get_vikunja_headers():
"Content-Type": "application/json", "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(): def get_tasks():
""" """
Obtiene y formatea la lista de tareas de Vikunja. Obtiene y formatea la lista de tareas de Vikunja.

11
talia_bot/start_bot.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# Script to start the Talia Bot with correct PYTHONPATH
# Get the directory of the script
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Set PYTHONPATH to include the parent directory
export PYTHONPATH="$PYTHONPATH:$DIR/.."
# Run the bot
python3 "$DIR/main.py"