How to build a Telegram bot that only answers you, with long polling in Python A developer published a roughly 70-line standard-library Python design for a private Telegram bot that uses getUpdates long polling (timeout=25) instead of a webhook, so no public server or certificate is needed. The bot authorizes a single owner through a one-time eight-character code from secrets with a 10-minute TTL, stores the numeric from.id rather than a mutable username, and keeps the HTTP timeout (40s) above the long-poll hold. In local testing the long-poll loop made 3 requests over 60 idle seconds versus 60 for once-a-second short polling, and replies to held requests arrived 1.5 to 6.3 ms later. A Telegram bot is a convenient remote control for a script or an AI agent on your own machine. It works on every phone, needs no app of your own, and needs no public server. The catch is that a bot is public. Anyone can find its username and message it, and the token only proves that your script is the bot; it says nothing about who is writing. The osFoundry desktop app has a bridge like this for its agents, written in Rust telegram bridge.rs . Below is the same design in about 70 lines of standard-library Python, tested against a local stand-in for the Bot API, along with the one-line mistake that turned a quiet bot into 183,389 requests a minute. Telegram delivers messages in two ways. A webhook needs a public HTTPS URL that Telegram can call. getUpdates needs nothing exposed: the script asks for new messages, and with timeout=25 Telegram holds the request open for up to 25 seconds and answers as soon as one arrives. On a laptop that is the right choice. There is no port to open, no tunnel, and no certificate. python 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 } The HTTP timeout 40 s has to be longer than the long-poll hold 25 s . If it is shorter, every quiet cycle ends in a client-side timeout that looks like an outage. The app keeps the token in the OS keychain, not in a file. From Python, keyring.get password does the same, and an environment variable is fine for a test. Over 60 idle seconds, the long-poll loop made 3 requests and a once-a-second short poll made 60. A message that arrived while a request was being held got its reply 1.5 to 6.3 ms later in the local test. Over a real network, the round trip to Telegram is added on top. The bot must know which person it serves before it does anything. The app does this with a one-time code. You click "Pair a phone" on the machine, it shows eight random characters, and you send /pair CODE to the bot from your phone. Whoever sends a valid code within 10 minutes is the owner. php pending = {} code - created at paired = {} sender id - chat id def begin pairing : code = secrets.token hex 4 .upper pending code = time.time return code The code comes from secrets , not from anything guessable like the time or the bot name, because the code is the only thing that authorises a sender. It is also removed when used, so it works once. What gets stored is the numeric from.id , not the @username or the display name. Both of those can be changed, and a freed username can be registered by someone else. The chat id is stored next to it, for a reason that comes up below. python 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 Three rules carry the security, and each one comes from a specific failure noted in the app's source. Unknown senders get silence. A reply like "you are not paired" tells a stranger that the bot is connected to a real person's machine and invites them to keep trying. No reply looks the same as a bot nobody runs. Pairing only works in a private chat. A bot added to a group receives the group's messages, so pairing there would let every member give the agent instructions. A paired person is answered only in the chat they paired from. The app's comments describe an earlier version that checked the sender but replied to whatever chat the message came from. A paired user who added the bot to a group then had an agent answering in that group, where everyone could drive tools on the owner's machine. Authorisation is by sender and the reply goes to a chat, so the two have to be checked together. /unpair is handled before that chat check, so access can be removed from the phone, which is where you are when you need it. python 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 is how the script tells Telegram which updates it has seen. Anything below it is confirmed and dropped, and anything at or above it is sent again. The comment on that line is the important part. It is tempting to move the offset only after doing something with a message, since that feels like "processed". But a message from a stranger is deliberately ignored, so it is never confirmed, so it comes back on the next call, immediately, because there is a pending update and Telegram has no reason to hold the request. The backoff is for the other failure: a revoked or mistyped token fails on every request, and retrying at full speed is a 401 every few seconds forever. The ladder of 5, 15, 60 and then 300 seconds is the one the app uses. The local stand-in implements getUpdates offset confirmation and the timeout hold and sendMessage , and records every call. Each scenario injected one message and then read what the bot sent back. | Message | Reply | |---|---| | stranger: "hi" | none | | stranger: /pair 12345678 | That code is not valid or has expired. | | me, in a group: /pair CODE | Pair with me in a direct message, not in a group. | | me, in a DM: /pair code lower case | Paired. | | stranger, reusing my code | That code is not valid or has expired. | | me, in the DM: a question | the agent's answer | | me, in the group: a question | none | | me: /unpair , then a question | Unpaired., then none | Then four loops ran for 60 seconds, each against its own stand-in: | Loop | getUpdates calls in 60 s | |---|---| | long poll, no messages | 3 | | short poll every second, no messages | 60 | | one stranger message, offset advanced for every update | 4 | | one stranger message, offset advanced only for handled messages | 183,389 | On the stand-in, which answers from the same machine, the bug ran at about 3,000 requests a second. Against the real API, the loop would run as fast as the network round trip allows, with the same stranger's "hi" redelivered on every call and nothing newer ever arriving. One message from someone you will never answer is enough to start it. A real bridge also needs to cap replies under Telegram's 4,096-character limit the app cuts at 3,900, on a character boundary, because a byte slice can land in the middle of a Japanese character , and to send replies as plain text. With Markdown, Telegram rejects the whole message if an asterisk or underscore is unbalanced, and an agent writing about snake case code produces exactly that. Most important, the text that arrives is written by whoever holds the phone, so it should reach the agent as untrusted input, the same as an email. Pairing decides who may talk to the agent; it doesn't make what they say safe. The osFoundry https://osfoundry.io desktop app ships this bridge for its AI agents https://osfoundry.io/features/agents : Device → Background for the token, and "Pair a phone" in a channel's Agents panel. It is free from the osFoundry download page https://osfoundry.io/download .