so you want to build a telegram bot. cool. most tutorials tell you to install telegraf or some bloated framework. you don't need any of that.
i've built bots with 40+ commands and thousands of users using nothing but node.js and the telegram bot API directly. here's how to do it from zero.
why skip the frameworks
telegraf, grammY, node-telegram-bot-api — they all add abstraction you don't need. the telegram bot API is just HTTP requests. you already know how to do that.
plus when something breaks (and it will), you want to understand what's actually happening. not dig through some framework's source code at 2am.
step 1: get your bot token
talk to @botfather on telegram. send /newbot, pick a name, get your token. save it somewhere safe. don't commit it to github (yes people do this).
const TOKEN = process.env.BOT_TOKEN || require('fs').readFileSync('.token', 'utf8').trim();
const API = `https://api.telegram.org/bot${TOKEN}`;
i usually keep mine in a .token file and add it to .gitignore. env vars work too.
step 2: set up polling
there's two ways to get messages — webhooks and polling. polling is easier to start with. you just keep asking telegram "got anything new?"
const https = require('https');
let offset = 0;
function poll() {
const url = `${API}/getUpdates?offset=${offset}&timeout=30`;
https.get(url, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const body = JSON.parse(data);
if (body.ok && body.result.length > 0) {
for (const update of body.result) {
offset = update.update_id + 1;
handleUpdate(update);
}
}
} catch (e) {
console.error('parse error:', e.message);
}
poll(); // loop forever
});
}).on('error', (e) => {
console.error('poll error:', e.message);
setTimeout(poll, 3000); // retry after 3s
});
}
the timeout=30 is long polling. telegram holds the connection open for 30 seconds and sends you updates as they come in. way more efficient than spamming requests.
step 3: handle messages
function handleUpdate(update) {
const msg = update.message;
if (!msg || !msg.text) return;
const chatId = msg.chat.id;
const text = msg.text.trim();
const command = text.split(' ')[0].toLowerCase();
switch (command) {
case '/start':
sendMessage(chatId, 'hey! bot is alive.');
break;
case '/help':
sendMessage(chatId, 'commands:\n/start - wake me up\n/help - this\n/time - current time');
break;
case '/time':
sendMessage(chatId, `it's ${new Date().toUTCString()}`);
break;
default:
if (text.startsWith('/')) {
sendMessage(chatId, 'unknown command. try /help');
}
}
}
step 4: send messages back
function sendMessage(chatId, text, options = {}) {
const payload = JSON.stringify({
chat_id: chatId,
text: text,
parse_mode: options.parse_mode || 'HTML',
...options
});
const req = https.request(`${API}/sendMessage`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
const body = JSON.parse(data);
if (!body.ok) console.error('send failed:', body.description);
});
});
req.write(payload);
req.end();
}
nothing fancy. just POST a JSON body to the telegram API. you can add inline keyboards, photos, whatever — it's all just different fields in the payload.
step 5: add inline keyboards
this is where bots get interactive. buttons that users can tap.
function sendWithButtons(chatId, text, buttons) {
sendMessage(chatId, text, {
reply_markup: JSON.stringify({
inline_keyboard: buttons.map(row =>
row.map(btn => ({
text: btn.text,
callback_data: btn.data
}))
)
})
});
}
// usage
sendWithButtons(chatId, 'pick one:', [
[{ text: '✅ Yes', data: 'yes' }, { text: '❌ No', data: 'no' }]
]);
then handle the callback in your update handler:
function handleUpdate(update) {
if (update.callback_query) {
const cb = update.callback_query;
const data = cb.data;
const chatId = cb.message.chat.id;
if (data === 'yes') {
sendMessage(chatId, 'you picked yes!');
}
// answer the callback to remove loading state
https.get(`${API}/answerCallbackQuery?callback_query_id=${cb.id}`);
return;
}
// ... rest of message handling
}
step 6: persist data
for simple stuff, just use JSON files. no need for mongodb or postgres when you're starting out.
const fs = require('fs');
function loadData(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return {};
}
}
function saveData(file, data) {
fs.writeFileSync(file, JSON.stringify(data, null, 2));
}
// track users
const users = loadData('users.json');
// in your /start handler:
users[chatId] = { joined: Date.now(), username: msg.from.username };
saveData('users.json', users);
this scales fine up to maybe 10k users. after that, consider sqlite.
the full starter template
const https = require('https');
const fs = require('fs');
const TOKEN = process.env.BOT_TOKEN || fs.readFileSync('.token', 'utf8').trim();
const API = `https://api.telegram.org/bot${TOKEN}`;
let offset = 0;
function sendMessage(chatId, text, opts = {}) {
const payload = JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML', ...opts });
const req = https.request(`${API}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
});
req.write(payload);
req.end();
}
function handleUpdate(update) {
const msg = update.message;
if (!msg?.text) return;
const chatId = msg.chat.id;
const cmd = msg.text.split(' ')[0].toLowerCase();
if (cmd === '/start') sendMessage(chatId, 'bot is running.');
if (cmd === '/help') sendMessage(chatId, '/start /help /ping');
if (cmd === '/ping') sendMessage(chatId, 'pong 🏓');
}
function poll() {
https.get(`${API}/getUpdates?offset=${offset}&timeout=30`, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
try {
const b = JSON.parse(d);
if (b.ok) b.result.forEach(u => { offset = u.update_id + 1; handleUpdate(u); });
} catch {}
poll();
});
}).on('error', () => setTimeout(poll, 3000));
}
console.log('bot started');
poll();
save that, run node bot.js, and you've got a working telegram bot with zero dependencies.
scaling up
once you outgrow this, you'll want:
- command router — a map of command names to handler functions instead of a giant switch
- rate limiting — track requests per user per minute
- error handling — wrap everything in try/catch, log errors to a file
- graceful shutdown — handle SIGINT/SIGTERM
i built @solscanitbot this way — pure node.js, no frameworks, 4000+ lines. it handles solana wallet tracking, token scanning, price alerts, and more. the approach scales better than you'd think.
if you want to see a real-world example of this pattern taken to production, check out the bot source code — it shows how to structure a large bot with dozens of commands, background workers, and inline keyboards all without a single framework dependency.
the telegram bot API is one of the best-documented APIs out there. you don't need a framework to wrap it. just read the docs, write some HTTP requests, and ship it.
Top comments (0)