Anatomía de un Bot de Telegram a Escala: 10,174 Líneas y 5 Patrones Anti-Pattern
Share
Anatomía de un Bot de Telegram a Escala: 10,174 Líneas y 5 Patrones Anti-Pattern
Introducción
Cuando un bot de Telegram crece de 7,500 a 10,174 líneas en 48 horas, algo está mal. No hablamos de features nuevos, sino de código duplicado, accesos redundantes a disco, y patrones que funcionan... hasta que no funcionan.
Este artículo disecciona un bot real en producción: un sistema de gestión de préstamos que maneja transacciones financieras vía Telegram. Lo que empezó como un prototipo ágil terminó siendo un case study perfecto de cómo la velocidad sin diseño genera deuda técnica invisible.
El Contexto: Un Bot Que Creció Demasiado Rápido
El bot analizado (`claude_proxy.py`) corre en un contenedor Docker, maneja callbacks de Telegram, y procesa operaciones financieras críticas. Su arquitectura:
- Flask como framework base
- Gemini API para procesamiento de lenguaje natural
- JSON file-based DB para persistencia
- 10,174 líneas de Python en un solo archivo
- 142 handlers de callbacks inline
La arquitectura suena razonable para un MVP. El problema no es la elección de stack, sino los patrones que emergieron bajo presión de entregas rápidas.
Problema #1: El Patrón del Handler Dual (CRÍTICO)
¿Qué es?
El bot tiene dos rutas HTTP separadas manejando los mismos callbacks:
- Handler genérico (`/claude`, línea 3433): ~3,000 líneas
- Handler dedicado (`/callback/er0002`, línea 7264): ~2,500 líneas
Cada handler contiene bloques `elif callback_data.startswith("er_qabono_")` idénticos, palabra por palabra.
Evidencia
El handler `er_qabono_` (Quick Abono) aparece en:
- Línea 4439 (handler genérico)
- Línea 8207 (handler dedicado)
Impacto Real
- Bug fix doble: Cada corrección debe aplicarse 2 veces. Si olvidas uno, tienes un bug silencioso que aparece solo en ciertos flujos.
- Crecimiento exponencial: El archivo creció +1,835 líneas en 2 días. No por features, sino por duplicación.
- Merge conflicts: Equipos con >1 dev tocan las mismas funciones en ubicaciones diferentes.
¿Por qué sucedió?
Telegram tiene un diseño peculiar: los callbacks pueden llegar por webhooks genéricos O por webhooks específicos según configuración de `allowed_updates`. El dev original manejó ambos casos... copiando y pegando el handler completo.
Problema #2: 66 Veces el Mismo I/O
El Patrón
En 66 lugares diferentes del código:
with open("/data/er0002/emma_db.json", "r") as dbf:
db = json.load(dbf)
activos = db.get("prestamos_activos", [])
prestamo = None
for p in activos:
if str(p.get("id", "")) == prestamo_id:
prestamo = p
break
Cada handler abre el archivo JSON, deserializa, itera linealmente para buscar un préstamo por ID.
Costos Ocultos
- I/O redundante: Si 10 usuarios presionan botones simultáneamente, 10 lecturas del mismo archivo de 500KB.
- Búsqueda O(n): Iterar sobre todos los préstamos para encontrar uno. Con 200 préstamos activos, cada búsqueda toca 200 objetos.
- Sin cache: El archivo no cambió entre callbacks, pero se lee cada vez.
- Manejo de errores inconsistente: Solo 12 de los 66 usos tienen try/except.
Alternativas Reales
- In-memory dict: `prestamos_cache = {p["id"]: p for p in activos}` → búsqueda O(1)
- SQLite: 500KB de JSON caben perfectamente en SQLite con índices
- Redis: Para alta concurrencia, cache con TTL de 30s
Problema #3: Callbacks Sin Respuesta
Telegram Bot API tiene un endpoint crucial que el 90% de los devs ignora: `answerCallbackQuery`.
El Problema
El bot tiene 142 handlers de callback (bloques `elif callback_data.startswith(...)`).
Solo 4 handlers llaman a `answerCallbackQuery`.
¿Qué Significa?
Cuando un usuario presiona un botón:
- Telegram muestra un spinner en el botón
- Espera que el bot responda con `answerCallbackQuery` (ack del callback)
- Si no hay respuesta en 30 segundos, Telegram reintenta el callback
- El spinner sigue visible hasta que haya respuesta o timeout
Fix de 2 Líneas
requests.post(
f"https://api.telegram.org/bot{TOKEN}/answerCallbackQuery",
json={"callback_query_id": callback_query_id}
)
Esto mata el spinner y confirma la acción. Sin esto, tienes UX rota y logs llenos de "callback already answered" warnings.
Problema #4: Renderizado de Cards Inline
Cada handler que muestra información de un préstamo construye el mensaje con 30-50 líneas de f-strings:
letra_info = f" [{prestamo.get('letra', '')}]" if prestamo.get('letra') else ""
pagos = prestamo.get("pagos_realizados", 0)
total_p = prestamo.get("pagos_total", 0)
saldo = prestamo.get("saldo_pendiente", 0)
pct = int((pagos / total_p) * 100) if total_p > 0 else 0
filled = pct // 10
bar = "█" filled + "░" (10 - filled)
card_text = (
f"👤 {prestamo['cliente']}{letra_info}\n"
f"━" * 27 + "\n"
f"💵 Préstamo: ${prestamo.get('prestamo', 0):,.0f}\n"
f"💰 Saldo: ${saldo:,.0f}\n"
f"📊 Progreso: [{bar}] {pct}%\n"
# ... 15 líneas más
)
Este bloque aparece 15 veces en el código, con variaciones mínimas (algunos incluyen fecha, otros no).
Por Qué Es Problemático
- Cambio de formato = 15 edits: Si el cliente quiere cambiar el emoji de 💵 a 💳, tocas 15 lugares.
- Bugs de formato: Algunos handlers usan `prestamo['cliente']`, otros `prestamo.get('cliente', 'N/A')`. Inconsistencia = crashes.
- Testing imposible: No puedes hacer unit test de "renderizado de card" porque no existe como función.
Solución de 10 Minutos
def render_prestamo_card(prestamo: dict, include_fecha: bool = False) -> str:
letra_info = f" [{prestamo.get('letra', '')}]" if prestamo.get('letra') else ""
# ... lógica de renderizado
return card_text
En cada handler:
card_text = render_prestamo_card(prestamo)
Ahora el formato vive en UN lugar, es testeable, y cualquier cambio se propaga automáticamente.
Problema #5: Botones Inline Hardcodeados
Pattern repetido 20+ veces:
botones = [[
{"text": "✅ Confirmar", "callback_data": f"confirm_{token}"},
{"text": "❌ Cancelar", "callback_data": f"cancel_{token}"}
]]
Parece inofensivo. Pero:
- Cambio de texto = 20 edits: Si quieres "Confirmar acción" en lugar de "Confirmar", tocas 20 lugares.
- Sin tipado: `callback_data` es string libre. Typo en "confrim_" pasa desapercibido hasta producción.
- No reutilizable: Botones de navegación (Atrás, Menú) se redefinen en cada handler.
Mejor Patrón
def build_confirm_cancel_buttons(action_token: str) -> list:
return [[
{"text": "✅ Confirmar", "callback_data": f"confirm_{action_token}"},
{"text": "❌ Cancelar", "callback_data": f"cancel_{action_token}"}
]]
def build_nav_buttons(current_menu: str) -> list:
return [[
{"text": "🔙 Atrás", "callback_data": f"back_{current_menu}"},
{"text": "🏠 Menú", "callback_data": "main_menu"}
]]
Consecuencias en Producción
Bugs Reales Observados
- Bug de Quick Abono: Fix aplicado en handler genérico, persiste en handler dedicado. Usuarios reportan "a veces funciona, a veces no".
- Race condition en DB: 2 callbacks simultáneos leen el mismo estado, ambos escriben, último commit gana. Pérdida de datos.
- Spinner infinito: 70% de los callbacks no responden con `answerCallbackQuery`. Usuarios presionan 2-3 veces, generan transacciones duplicadas.
Métricas de Mantenibilidad
- Tiempo de fix promedio: 45min (buscar duplicados, aplicar en ambos handlers, probar ambos flujos)
- Cobertura de tests: 0% (código no testeable por diseño)
- Onboarding de nuevo dev: 2-3 días para entender "por qué hay 2 handlers"
Lecciones Aprendidas
1. La Velocidad Tiene Costos Diferidos
Duplicar código ahorra 15 minutos hoy. Te cuesta 2 horas la próxima semana cuando necesitas un fix urgente.
2. Los Archivos Monolíticos Ocultan Problemas
10,174 líneas en un archivo = búsquedas lentas, diffs gigantes, merge conflicts constantes. Si tu archivo no cabe en una pantalla, probablemente necesita refactoring.
3. File-Based DB No Escala
500KB de JSON leído 66 veces por request funcionan con 5 usuarios. Con 50 usuarios concurrentes, el disco es tu cuello de botella.
4. UX = Detalles Técnicos
`answerCallbackQuery` parece un detalle de implementación. Pero es la diferencia entre "este bot es profesional" y "este bot está roto".
Conclusión
Este bot funciona. Procesa transacciones reales, tiene usuarios activos, y cumple su propósito. Pero su arquitectura interna es una bomba de tiempo.
Los 5 problemas identificados no son errores de código, son decisiones de diseño bajo presión. El dev original eligió velocidad sobre estructura, y funcionó... hasta que el bot creció.
La pregunta no es "¿cómo evitamos estos errores?", sino "¿cuándo invertimos en refactoring?". La respuesta: cuando el costo de mantener el código actual supera el costo de reescribirlo.
Para este bot, ese punto llegó en la línea 8,000. A las 10,174 líneas, el refactoring ya no es opcional.
---
Takeaways:
- Duplicación de código no es visible en métricas de producto, pero duplica el costo de cada cambio futuro
- I/O repetido funciona en desarrollo, falla en producción con carga real
- Detalles de UX como `answerCallbackQuery` son la diferencia entre software amateur y profesional
El mejor momento para refactorizar fue ayer. El segundo mejor momento es hoy.