Varela Insights
📚 Archivo. Este contenido se publicó originalmente en el blog de Varela Insights y se conserva en su dirección original. El contenido nuevo vive en varelainsights.com/blog.

Anatomía de un Bot de Telegram a Escala: 10,174 Líneas y 5 Patrones Anti-Pattern

Publicado el 7 feb 2026 · Irving Varela
Anatomía de un Bot de Telegram a Escala: 10,174 Líneas y 5 Patrones Anti-Pattern

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:

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:

  1. Handler genérico (`/claude`, línea 3433): ~3,000 líneas
  2. 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:

Código 100% duplicado: 298 líneas de lógica de negocio, construcción de mensajes, validaciones de monto, y renderizado de botones.

Impacto Real

¿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

  1. I/O redundante: Si 10 usuarios presionan botones simultáneamente, 10 lecturas del mismo archivo de 500KB.
  2. Búsqueda O(n): Iterar sobre todos los préstamos para encontrar uno. Con 200 préstamos activos, cada búsqueda toca 200 objetos.
  3. Sin cache: El archivo no cambió entre callbacks, pero se lee cada vez.
  4. Manejo de errores inconsistente: Solo 12 de los 66 usos tienen try/except.

Alternativas Reales

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:

  1. Telegram muestra un spinner en el botón
  2. Espera que el bot responda con `answerCallbackQuery` (ack del callback)
  3. Si no hay respuesta en 30 segundos, Telegram reintenta el callback
  4. El spinner sigue visible hasta que haya respuesta o timeout
UX real: Usuario presiona "Abonar $500", el botón muestra spinner infinito, el usuario presiona de nuevo (pensando que no funcionó), genera callback duplicado.

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

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:

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

  1. Bug de Quick Abono: Fix aplicado en handler genérico, persiste en handler dedicado. Usuarios reportan "a veces funciona, a veces no".
  2. Race condition en DB: 2 callbacks simultáneos leen el mismo estado, ambos escriben, último commit gana. Pérdida de datos.
  3. Spinner infinito: 70% de los callbacks no responden con `answerCallbackQuery`. Usuarios presionan 2-3 veces, generan transacciones duplicadas.

Métricas de Mantenibilidad

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:

  1. Duplicación de código no es visible en métricas de producto, pero duplica el costo de cada cambio futuro
  2. I/O repetido funciona en desarrollo, falla en producción con carga real
  3. 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.

Audita la visibilidad de tu negocio en IA →