I wanted a small bot that answers one question: what DNS records does this domain have? I did not want another VPS, another process to watch, or another deploy pipeline to babysit. Telegram Serverless was in early access, and it offered the opposite shape. I write JavaScript modules, the platform runs them in a V8 isolate, and there is no server of mine anywhere in the loop.
The runtime sets hard limits. There is no filesystem and no npm packages at runtime. The only imports are the platform SDK and my own modules under tgcloud/. That ruled out most bot ideas before I wrote anything. DNS fit because the whole job is one outbound HTTP request and a small JSON answer back.
The lookup is DNS over HTTPS
The bot never speaks UDP port 53. It asks Cloudflare first, and Google if Cloudflare fails, using the JSON form of DNS over HTTPS:
const urls = [ `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`, `https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${type}`,];Both requests send accept: application/dns-json. A response with status 0 is the answer. Anything else becomes an error message, including NXDOMAIN, which the code spells out as name not found instead of passing a status code through.
One shared module, tgcloud/lib/dns.js, holds the lookup, the formatting, and the cache. Every way of asking the bot goes through it. That was a deliberate choice. I did not want four handlers drifting into four slightly different ideas of what a DNS answer looks like.
The parser lowercases the name and strips a trailing dot, then checks it against a regex that caps labels at 63 characters and the whole name at 253. A bare domain defaults to A records. /dns example.com MX asks for MX. The supported types are A, AAAA, CNAME, MX, NS, TXT, and SOA. Typing a URL or a sentence gets an error that shows the expected format.
No network call happens here. The steps show what the parser and the lookup do, in order.
Answers come back as HTML formatted for Telegram, with the domain in bold, the record type in code, and a DNSSEC mark when the upstream sets the AD bit. Each answer line shows the record value and its type and TTL. No records means the reply says so plainly.
Four ways to ask, one lookup
The chat handler is the obvious one. Send a domain to the bot, or use /dns, and get the answer in the chat. Under the answer sit buttons for the other record types. Tapping MX edits the same message in place instead of sending a new one. The buttons carry dns:example.com:MX as callback data, and the active type is left out of the keyboard, so every button shown would change the answer.
example.comA
93.184.216.34A · ttl 300
Sample data. The buttons only swap what is shown here. No lookup leaves this page.
Inline mode works from any chat. Type @yourbot example.com and pick the result to drop it into the conversation, without opening the bot first. The handler answers with one article and sets a 300 second cache time for the inline result itself. An empty query gets a hint.
Guest Chat Mode is separate from inline mode. It is a Bot API 10.0 feature. You mention the bot in a group where it is not a member, sent as a regular message, and Telegram delivers a one-off guest_message update. The bot only sees that mentioning message. It gets one reply, through answerGuestQuery, not sendMessage, because it is not a chat member. The handler strips the mention and reuses the same lookup, so a guest mention and a direct message return the same answer.
Then there is the Mini App. A static front end under web/ takes a domain and a record type and shows structured answers, with recent lookups kept in the browser and tap to copy on each record. It calls an endpoint, POST /api/resolve, which returns the raw answer records for the front end to render. The platform verifies the Mini App init data before the endpoint runs, so opening the raw URL in a browser fails. That failure confused me the first time I tried it outside Telegram. The endpoint only accepts calls carrying verified init data, and the error message says the page was opened outside Telegram.
The cache is one table
The cache table has three columns: a key, a body, and an expiry time. The key is example.com|A. The body is the formatted answer text. The expiry comes from the largest TTL in the answer, clamped to a floor of 60 seconds and a ceiling of one hour. Answers with no records get 300 seconds. A hit returns the stored text with a small “from cache” note. A miss queries upstream, stores the result with an upsert, and deletes expired rows while it is there.
Caching formatted text is a tradeoff. The chat handlers want formatted text, and storing it means a cache hit needs no work. The Mini App endpoint skips the cache entirely and calls the raw query function, because it needs structured records. Each caller gets the shape it needs, with one upstream call when the cache cannot help.
Record TTL: 300s. Stored for: 300s.
Inside the 60 second to 1 hour range, so the value is stored as-is.
The deploy gotchas were all state
Deploying never touches the database. npx tgcloud push sends the code. npx tgcloud migrate applies schema changes, and it asks for confirmation. I learned the split when the bot went live. The first lookup failed with no such table: dns_cache, and the fix was running the migrate command I had skipped. The message handler now catches that specific error and tells the user the database is not set up yet, instead of showing the raw database error.
CI had its own version of the same problem. Every GitHub Actions run starts from a clean checkout with no .tgcloud/ sync state, so the first pushes failed with “never synced with it”. The workflow now runs npx tgcloud fetch before push, which pulls the baseline first. Fetch before push is stateless and correct for a fresh checkout. Persisting .tgcloud/ as a CI artifact would have worked until the artifact expired or a deploy happened outside CI, and then the same error would be back.
The last loose end is language. I asked for everything in English, including user-facing strings and the README. The conversion covered the handlers, the Mini App, and the schema comments. It missed two strings in the callback handler, which still say “Tipe tidak dikenal” and “Gagal”. I know because I read the file again while writing this. Those two strings are still there.
What it cannot do
The runtime only offers outbound HTTP, so there is no classic DNS over UDP or DNS over TLS here, only DoH through Cloudflare with a Google fallback. Your queries are visible to the DoH upstream and to Telegram. It is a utility for checking records from inside a chat, not a place to look up anything sensitive.
That boundary is also why the bot stayed small. Four handlers, one shared lookup module, one endpoint, one table, and a static front end, with no queue, worker, or server to restart. When I want to check a domain now, I open Telegram and type it. The bot answers, and there is nothing else running that I need to remember.