Un bot de Telegram es un mando a distancia cómodo para un script o un agente de IA en tu propia máquina. Funciona en cualquier móvil, no necesita una app propia y no necesita un servidor público. El inconveniente es que un bot es público. Cualquiera puede encontrar su nombre de usuario y escribirle, y el token solo demuestra que tu script es el bot; no dice nada de quién escribe. La aplicación de escritorio de osFoundry tiene un puente así para sus agentes, escrito en Rust (telegram_bridge.rs). Aquí tienes el mismo diseño en unas 70 líneas de Python con solo la biblioteca estándar, probado contra un sustituto local de la Bot API, junto con el error de una línea que convirtió un bot tranquilo en 183.389 peticiones por minuto.
Long polling en lugar de un webhook
Telegram entrega los mensajes de dos maneras. Un webhook necesita una URL HTTPS pública a la que Telegram pueda llamar. getUpdates no necesita exponer nada: el script pide mensajes nuevos y, con timeout=25, Telegram mantiene la petición abierta hasta 25 segundos y responde en cuanto llega uno. En un portátil es la opción correcta. No hay que abrir ningún puerto, ni túnel, ni certificado.
import json, os, secrets, time, urllib.parse, urllib.request API = os.environ.get("TG_API", "https://api.telegram.org") TOKEN = os.environ["TG_TOKEN"] LONG_POLL = 25 PAIR_TTL = 600 def call(method, params=None, body=None, timeout=40): url = f"{API}/bot{TOKEN}/{method}" if params: url += "?" + urllib.parse.urlencode(params) data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data, {"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=timeout) as r: return json.load(r) def send(chat_id, text): call("sendMessage", body={"chat_id": chat_id, "text": text[:3900]}) El timeout HTTP (40 s) tiene que ser mayor que la espera del long poll (25 s). Si es menor, cada ciclo tranquilo acaba en un timeout del cliente que parece una caída. La aplicación guarda el token en el llavero del sistema, no en un archivo. Desde Python, keyring.get_password hace lo mismo, y una variable de entorno sirve para una prueba.
En 60 segundos de inactividad, el bucle de long poll hizo 3 peticiones y un short poll cada segundo hizo 60. Un mensaje que llegó mientras se mantenía una petición recibió su respuesta entre 1,5 y 6,3 ms después en la prueba local. En una red real se suma además la ida y vuelta hasta Telegram.
Emparejamiento: quién puede hablar
El bot tiene que saber a qué persona sirve antes de hacer nada. La aplicación lo hace con un código de un solo uso. Pulsas "Pair a phone" en la máquina, muestra ocho caracteres aleatorios y envías /pair CODE al bot desde el móvil. Quien envíe un código válido en 10 minutos es el propietario.
pending = {} # code -> created_at paired = {} # sender id -> chat id def begin_pairing(): code = secrets.token_hex(4).upper() pending[code] = time.time() return code El código sale de secrets, no de algo adivinable como la hora o el nombre del bot, porque el código es lo único que autoriza a un remitente. Además se elimina al usarse, así que funciona una sola vez.
Lo que se guarda es el from.id numérico, no el @username ni el nombre visible. Ambos se pueden cambiar, y otra persona puede registrar un nombre de usuario que haya quedado libre. El id del chat se guarda a su lado, por un motivo que aparece más abajo.
Decidir qué hacer con un mensaje
def handle(msg, run_agent): text = (msg.get("text") or "").strip() sender = msg.get("from", {}).get("id") chat = msg["chat"] if not text or sender is None: return words = text.split() if words[0] == "/pair" or words[0].startswith("/pair@"): if chat.get("type") != "private": return send(chat["id"], "Pair with me in a direct message, not in a group.") code = words[1].upper() if len(words) > 1 else "" created = pending.pop(code, None) if created is None or time.time() - created > PAIR_TTL: return send(chat["id"], "That code is not valid or has expired.") paired[sender] = chat["id"] return send(chat["id"], "Paired. Message me and the agent will answer.") if text.lower() == "/unpair": gone = paired.pop(sender, None) is not None return send(chat["id"], "Unpaired." if gone else "You were not paired.") if paired.get(sender) != chat["id"] or chat.get("type") != "private": return # silence send(chat["id"], run_agent(text)) Tres reglas sostienen la seguridad, y cada una viene de un fallo concreto anotado en el código de la aplicación.
Los remitentes desconocidos reciben silencio. Una respuesta como "no estás emparejado" le dice a un extraño que el bot está conectado a la máquina de una persona real y le invita a seguir probando. Sin respuesta, parece un bot que nadie ejecuta.
El emparejamiento solo funciona en un chat privado. Un bot añadido a un grupo recibe los mensajes del grupo, así que emparejar ahí permitiría que cualquier miembro diera instrucciones al agente.
A una persona emparejada solo se le responde en el chat desde el que se emparejó. Los comentarios de la aplicación describen una versión anterior que comprobaba al remitente pero respondía al chat de donde viniera el mensaje. Un usuario emparejado que añadió el bot a un grupo tenía entonces un agente respondiendo en ese grupo, donde todos podían manejar herramientas en la máquina del propietario. La autorización es por remitente y la respuesta va a un chat, así que hay que comprobar ambos a la vez.
/unpair se gestiona antes de esa comprobación del chat, de modo que se puede retirar el acceso desde el móvil, que es donde estás cuando lo necesitas.
El bucle y el offset
def poll(run_agent): offset, failures = 0, 0 while True: try: res = call("getUpdates", {"timeout": LONG_POLL, "offset": offset, "allowed_updates": '["message"]'}) failures = 0 except Exception: failures += 1 time.sleep({1: 5, 2: 15, 3: 60}.get(failures, 300)) continue for u in res["result"]: offset = max(offset, u["update_id"] + 1) # even for messages we ignore if "message" in u: handle(u["message"], run_agent) offset es la forma en que el script le dice a Telegram qué actualizaciones ha visto. Lo que está por debajo queda confirmado y se descarta, y lo que está en ese valor o por encima se envía de nuevo. El comentario de esa línea es lo importante. Es tentador mover el offset solo después de hacer algo con el mensaje, porque parece "procesado". Pero un mensaje de un extraño se ignora a propósito, así que nunca se confirma, así que vuelve en la siguiente llamada, de inmediato, porque hay una actualización pendiente y Telegram no tiene motivo para mantener la petición abierta.
El backoff es para el otro fallo: un token revocado o mal escrito falla en cada petición, y reintentar a toda velocidad es un 401 cada pocos segundos para siempre. La escalera de 5, 15, 60 y luego 300 segundos es la que usa la aplicación.
Qué mostraron las pruebas
El sustituto local implementa getUpdates (confirmación por offset y espera por timeout) y sendMessage, y registra cada llamada. Cada escenario inyectó un mensaje y luego leyó lo que el bot enviaba.
| Mensaje | Respuesta |
|---|---|
| extraño: "hi" | ninguna |
extraño: /pair 12345678 | That code is not valid or has expired. |
yo, en un grupo: /pair CODE | Pair with me in a direct message, not in a group. |
yo, en un DM: /pair code (minúsculas) | Paired. |
| extraño, reutilizando mi código | That code is not valid or has expired. |
| yo, en el DM: una pregunta | la respuesta del agente |
| yo, en el grupo: una pregunta | ninguna |
yo: /unpair, y luego una pregunta | Unpaired., y luego ninguna |
Después se ejecutaron cuatro bucles durante 60 segundos, cada uno contra su propio sustituto:
| Bucle | llamadas a getUpdates en 60 s |
|---|---|
| long poll, sin mensajes | 3 |
| short poll cada segundo, sin mensajes | 60 |
| un mensaje de un extraño, offset avanzado para cada actualización | 4 |
| un mensaje de un extraño, offset avanzado solo para mensajes atendidos | 183.389 |
En el sustituto, que responde desde la misma máquina, el fallo corrió a unas 3.000 peticiones por segundo. Contra la API real, el bucle iría tan rápido como permita la ida y vuelta de la red, con el mismo "hi" del extraño reenviado en cada llamada y sin que llegue nada más nuevo. Un solo mensaje de alguien a quien nunca vas a responder basta para empezar.
Lo que queda fuera
Un puente real también necesita limitar las respuestas por debajo del límite de 4.096 caracteres de Telegram (la aplicación corta en 3.900, en un límite de carácter, porque un corte por bytes puede caer en mitad de un carácter japonés), y enviar las respuestas como texto plano. Con Markdown, Telegram rechaza el mensaje entero si un asterisco o un guion bajo no está equilibrado, y un agente que escribe sobre código en snake_case produce justo eso. Lo más importante: el texto que llega lo escribe quien tenga el móvil, así que debe llegar al agente como entrada no fiable, igual que un correo. El emparejamiento decide quién puede hablar con el agente; no hace seguro lo que dice.
La aplicación de escritorio osFoundry incluye este puente para sus agentes de IA: Device → Background para el token, y "Pair a phone" en el panel Agents de un canal. Es gratuita desde la página de descarga de osFoundry.
