Building Telegram & Discord Bots from Scratch: A Complete Guide
Part 1: Introduction#
What Are Chat Bots and Why Build Them?#
Chat bots are automated programs that interact with users through messaging platforms. Instead of building a mobile app (expensive, slow adoption), you meet users where they already are — Telegram, Discord, WhatsApp.
For our project, we had a car listings website (cars.yourdomain.com) and wanted to let users browse cars, compare prices, calculate EMIs, and get price alerts — all without leaving their chat app.
Telegram vs Discord Bot Ecosystems#
| Aspect | Telegram | Discord |
|---|---|---|
| API Style | HTTP-based, simple | WebSocket + REST, more complex |
| Commands | Text commands (/start) + inline keyboards | Slash commands (form-based UI) |
| Rich Content | Photos + Markdown captions | Embeds (rich cards) + attachments |
| Deployment | Polling OR webhooks | WebSocket (always connected) |
| User Discovery | Anyone can find your bot via @username | Users must be in your server |
| Inline Mode | Yes — use bot in any chat | No equivalent |
Bottom line: Telegram is simpler to start with. Discord has richer UI components. We’ll build for both.
What We’ll Build#
- CarKunda Bot (Telegram) — Full-featured car info bot with MongoDB, images, webhooks, inline mode
- CarKunda Bot (Discord) — Same features, adapted for Discord’s slash command system
Part 2: Prerequisites#
Server & Tools Setup#
You’ll need a VPS running Ubuntu. Here’s the quick setup:
# Node.js (v22 via NodeSource)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# PM2 — process manager (keeps bots alive)
sudo npm install -g pm2
# MongoDB
sudo apt install -y mongodb
sudo systemctl enable mongodb
# Nginx (reverse proxy for webhooks)
sudo apt install -y nginx
# Certbot (free SSL certificates)
sudo apt install -y certbot python3-certbot-nginx
Domain & DNS (Optional but Recommended for Webhooks)#
If you’re using webhooks (recommended for production Telegram bots), you need HTTPS. The easiest setup:
- Buy a domain or use a subdomain (e.g.,
carkunda.yourdomain.com) - Point it to your VPS IP via Cloudflare DNS (A record)
- Use Certbot for SSL
We’ll cover the full setup in Part 6.
Part 3: Telegram Bot — From Zero to Production#
3.1 Creating a Bot via BotFather#
Open Telegram and search for @BotFather. This is the official bot that creates other bots.
Step 1: Create the bot
/newbot
BotFather asks for a name (display name) and username (must end in bot). You’ll get a token like:
YOUR_TELEGRAM_BOT_TOKEN
⚠️ Keep this token secret. Anyone with it controls your bot.
Step 2: Set commands menu
/setcommands
Select your bot, then paste:
cars - Browse all cars
brands - List all brands
search - Search for a car
ev - Electric vehicles
compare - Compare two cars
random - Random car pick
wishlist - Your saved cars
emi - EMI calculator
subscribe - Price alerts
help - Show all commands
This creates the / menu that appears when users type / in your bot.
You can also set commands programmatically via the API:
curl -X POST "https://api.telegram.org/bot<YOUR_TOKEN>/setMyCommands" \
-H "Content-Type: application/json" \
-d '{
"commands": [
{"command": "cars", "description": "Browse all cars"},
{"command": "brands", "description": "List all brands"},
{"command": "search", "description": "Search for a car"},
{"command": "ev", "description": "Electric vehicles"},
{"command": "compare", "description": "Compare two cars"},
{"command": "random", "description": "Random car pick"},
{"command": "wishlist", "description": "Your saved cars"},
{"command": "emi", "description": "EMI calculator"},
{"command": "subscribe", "description": "Price alerts"},
{"command": "help", "description": "Show all commands"}
]
}'
Step 3: Enable inline mode
/setinline
This lets users type @YourBot query in any chat to search and share results. Very powerful for discovery.
3.2 Advanced Bot: CarKunda (MongoDB + Images + Webhooks)#
Now let’s build something serious. CarKunda is a full car information bot with:
- Browsing and searching cars from MongoDB
- Sending car photos from local files
- Inline keyboards (buttons)
- Inline mode (use in any chat)
- Per-user wishlists
- EMI calculator
- Price change alerts
- Subscriber notifications
Project setup:
mkdir nepalcars-bot && cd nepalcars-bot
npm init -y
npm install node-telegram-bot-api mongodb express
Full code — index.js:
const TelegramBot = require('node-telegram-bot-api');
const { MongoClient } = require('mongodb');
const express = require('express');
const fs = require('fs');
const path = require('path');
// ===== CONFIG =====
const BOT_TOKEN = 'YOUR_TELEGRAM_BOT_TOKEN';
const MONGO_URI = 'mongodb://localhost:27017';
const DB_NAME = 'nepalcars';
const SITE_URL = 'https://cars.yourdomain.com';
const IMAGES_BASE = '/var/www/your-app/public/images/cars';
const WEBHOOK_URL = 'https://carkunda.yourdomain.com';
const PORT = 3200;
// ==================
const bot = new TelegramBot(BOT_TOKEN, { webHook: false });
let db;
async function connectDB() {
const client = new MongoClient(MONGO_URI);
await client.connect();
db = client.db(DB_NAME);
console.log('Connected to MongoDB');
}
Helpers: Formatting & Image Lookup#
function formatPrice(npr) {
if (!npr) return 'N/A';
return `Rs. ${(npr / 100000).toFixed(2)} Lakh`;
}
function getImagePath(car) {
if (!car || !car.slug) return null;
// Try 1.jpg, then {slug}-1.jpg, then first file in directory
for (const ext of ['jpg', 'jpeg', 'png', 'webp']) {
const p = path.join(IMAGES_BASE, car.slug, `1.${ext}`);
if (fs.existsSync(p)) return p;
}
for (const ext of ['jpg', 'jpeg', 'png', 'webp']) {
const p = path.join(IMAGES_BASE, car.slug, `${car.slug}-1.${ext}`);
if (fs.existsSync(p)) return p;
}
const dir = path.join(IMAGES_BASE, car.slug);
if (fs.existsSync(dir)) {
const files = fs.readdirSync(dir).filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f)).sort();
if (files.length) return path.join(dir, files[0]);
}
return null;
}
// Escape Markdown v1 special characters
function escMd(str) {
if (!str) return '';
return String(str).replace(/([_*`\[])/g, '\\$1');
}
Sending Car Cards with Photos + Inline Keyboards#
This is the core pattern — sending a photo with a caption and interactive buttons:
async function sendCarCard(chatId, car, detailed = false, extra = {}) {
const imgPath = getImagePath(car);
const keyboard = {
inline_keyboard: [
[
{ text: '📋 Details', callback_data: `detail_${car.slug}` },
{ text: '❤️ Wishlist', callback_data: `wish_add_${car.slug}` },
],
[
{ text: '🌐 View Online', url: `${SITE_URL}/cars/${car.slug}` },
],
],
};
const caption = formatCar(car, detailed);
const opts = { parse_mode: 'Markdown', reply_markup: keyboard };
if (imgPath) {
try {
// ⚠️ Telegram photo captions max 1024 chars!
const photoCaption = caption.length > 1024
? caption.substring(0, 1020) + '...'
: caption;
await bot.sendPhoto(chatId, imgPath, { ...opts, caption: photoCaption });
return;
} catch (e) {
console.error('Photo send failed:', car.slug, e.message);
}
}
// Fallback to text if no image
await bot.sendMessage(chatId, caption, { ...opts, disable_web_page_preview: true });
}
Key gotcha: Telegram photo captions are limited to 1024 characters. If your formatted text is longer, truncate it or the API will reject the entire message silently.
Command Handlers#
Telegram bot commands use regex matching via onText:
// /start — Welcome message + main menu
bot.onText(/\/start/, async (msg) => {
const welcome = `🚗 *Welcome to NepalCars Bot!*\n\nYour guide to cars available in Nepal.`;
bot.sendMessage(msg.chat.id, welcome, {
parse_mode: 'Markdown',
reply_markup: mainMenuKeyboard(),
});
// Upsert user in database
await db.collection('bot_users').updateOne(
{ chat_id: msg.chat.id },
{ $set: { chat_id: msg.chat.id, username: msg.from.username, first_name: msg.from.first_name, joined: new Date() },
$setOnInsert: { wishlist: [], subscribed: false } },
{ upsert: true }
);
});
// /search <query> — Regex captures the argument
bot.onText(/\/search\s+(.+)/, async (msg, match) => {
const query = match[1].trim();
const cars = await db.collection('cars').find({
$or: [
{ name: new RegExp(query, 'i') },
{ brand: new RegExp(query, 'i') },
{ body_type: new RegExp(query, 'i') },
]
}).toArray();
if (!cars.length) return bot.sendMessage(msg.chat.id, `No results for "${query}".`);
for (const car of cars.slice(0, 5)) {
await sendCarCard(msg.chat.id, car, true);
}
});
// /compare car1 vs car2
bot.onText(/\/compare\s+(.+?)\s+vs\s+(.+)/i, async (msg, match) => {
const car1 = await db.collection('cars').findOne({ name: new RegExp(match[1].trim(), 'i') });
const car2 = await db.collection('cars').findOne({ name: new RegExp(match[2].trim(), 'i') });
if (!car1 || !car2) return bot.sendMessage(msg.chat.id, 'Car not found.');
// ... build comparison text
});
The pattern onText(regex, callback) is how Telegram bots handle commands. The regex captures groups become match[1], match[2], etc.
Callback Queries (Button Clicks)#
When users tap inline keyboard buttons, you handle them via callback_query:
bot.on('callback_query', async (query) => {
const chatId = query.message.chat.id;
const msgId = query.message.message_id;
const data = query.data;
// Always acknowledge the callback
bot.answerCallbackQuery(query.id).catch(() => {});
if (data === 'menu_main') {
return bot.editMessageText('🚗 *NepalCars Bot* — Choose an option:', {
chat_id: chatId, message_id: msgId,
parse_mode: 'Markdown',
reply_markup: mainMenuKeyboard(),
}).catch(() => {});
}
if (data.startsWith('car_') || data.startsWith('detail_')) {
const slug = data.replace(/^(car_|detail_)/, '');
const car = await db.collection('cars').findOne({ slug });
if (car) await sendCarCard(chatId, car, true);
}
if (data.startsWith('wish_add_')) {
const slug = data.slice(9);
await db.collection('bot_users').updateOne(
{ chat_id: chatId },
{ $addToSet: { wishlist: slug } },
{ upsert: true }
);
bot.sendMessage(chatId, `❤️ Added to wishlist!`);
}
});
Important: Always call answerCallbackQuery() — otherwise users see a loading spinner on the button forever.
Inline Mode#
Inline mode lets users type @YourBot query in any chat to search and share results inline:
bot.on('inline_query', async (query) => {
const q = query.query.trim();
let filter = {};
if (q) {
filter = {
$or: [
{ name: new RegExp(q, 'i') },
{ brand: new RegExp(q, 'i') },
],
};
}
const cars = await db.collection('cars').find(filter).limit(20).toArray();
const results = cars.map(car => ({
type: 'article',
id: car.slug,
title: car.name,
description: `${car.brand} | ${car.price_display || formatPrice(car.price_min_npr)}`,
input_message_content: {
message_text: formatCar(car, false),
parse_mode: 'Markdown',
},
thumbnail_url: `${SITE_URL}/images/cars/${car.slug}/1.jpg`,
reply_markup: {
inline_keyboard: [[{ text: '🌐 View Online', url: `${SITE_URL}/cars/${car.slug}` }]],
},
}));
bot.answerInlineQuery(query.id, results, { cache_time: 60 });
});
EMI Calculator#
bot.onText(/\/emi(?:\s+(.+))?/, async (msg, match) => {
const arg = match[1] ? match[1].trim() : '';
if (!arg) return bot.sendMessage(msg.chat.id,
'Usage: /emi <amount_lakhs> <years> <rate%>\nExample: /emi 50 5 10');
const parts = arg.split(/\s+/);
const principal = parseFloat(parts[0]) * 100000;
const years = parseFloat(parts[1]);
const annualRate = parseFloat(parts[2]);
const monthlyRate = annualRate / 12 / 100;
const months = years * 12;
const emi = principal * monthlyRate * Math.pow(1 + monthlyRate, months)
/ (Math.pow(1 + monthlyRate, months) - 1);
bot.sendMessage(msg.chat.id,
`💵 Monthly EMI: Rs. ${Math.round(emi).toLocaleString()}\n` +
`💰 Total: Rs. ${Math.round(emi * months).toLocaleString()}`
);
});
Price Alerts & Subscriber Notifications#
The bot periodically checks for price changes and notifies subscribers:
async function checkPriceChanges() {
const cars = await db.collection('cars').find().toArray();
const snapshots = await db.collection('bot_meta').findOne({ key: 'price_snapshots' });
const oldPrices = (snapshots && snapshots.prices) || {};
const newPrices = {};
const changed = [];
for (const car of cars) {
newPrices[car.slug] = { min: car.price_min_npr, max: car.price_max_npr };
if (oldPrices[car.slug] && oldPrices[car.slug].min !== car.price_min_npr) {
changed.push({ car, oldMin: oldPrices[car.slug].min });
}
}
// Save new snapshot
await db.collection('bot_meta').updateOne(
{ key: 'price_snapshots' },
{ $set: { prices: newPrices, updated: new Date() } },
{ upsert: true }
);
// Notify subscribers about changes
if (changed.length) {
const subscribers = await db.collection('bot_users').find({ subscribed: true }).toArray();
for (const { car, oldMin } of changed) {
const direction = car.price_min_npr < oldMin ? '📉 Decreased' : '📈 Increased';
const text = `🔔 *Price Change!*\n🚗 ${car.name}\n${direction}\nOld: ${formatPrice(oldMin)}\nNew: ${formatPrice(car.price_min_npr)}`;
for (const sub of subscribers) {
bot.sendMessage(sub.chat_id, text, { parse_mode: 'Markdown' }).catch(() => {});
}
}
}
}
// Run hourly
setInterval(checkPriceChanges, 60 * 60 * 1000);
3.4 Polling vs Webhooks#
There are two ways your bot can receive messages from Telegram:
Polling — Your bot repeatedly asks Telegram: “Any new messages?” Simple, works everywhere, but wastes resources.
const bot = new TelegramBot(BOT_TOKEN, { polling: true });
Webhooks — Telegram pushes updates to YOUR server via HTTPS POST. Efficient, instant, but requires SSL.
const bot = new TelegramBot(BOT_TOKEN, { webHook: false }); // We handle it manually
When to use which:
- Development/testing → Polling (no SSL needed)
- Production on a VPS → Webhooks (efficient, instant)
- Serverless (Lambda, Vercel) → Webhooks (natural fit)
Setting Up Webhooks#
Step 1: Express server
const app = express();
app.use(express.json());
app.post(`/webhook/${BOT_TOKEN}`, (req, res) => {
bot.processUpdate(req.body);
res.sendStatus(200);
});
app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() }));
app.listen(PORT, '127.0.0.1', async () => {
await bot.setWebHook(`${WEBHOOK_URL}/webhook/${BOT_TOKEN}`);
console.log('Webhook set!');
});
Why 127.0.0.1? We don’t expose the Express server directly — nginx handles SSL and proxies to it.
Step 2: Nginx reverse proxy
server {
server_name carkunda.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:3200;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/carkunda.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/carkunda.yourdomain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
if ($host = carkunda.yourdomain.com) {
return 301 https://$host$request_uri;
}
listen 80;
server_name carkunda.yourdomain.com;
return 404;
}
Step 3: SSL with Certbot
# Add DNS A record first: carkunda.yourdomain.com → your VPS IP
# If using Cloudflare, set DNS to "DNS only" (grey cloud) for Certbot to work
sudo certbot --nginx -d carkunda.yourdomain.com
Step 4: Set webhook URL with Telegram
The bot does this automatically on startup via bot.setWebHook(), but you can also do it manually:
curl "https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://carkunda.yourdomain.com/webhook/<TOKEN>"
Step 5: Verify webhook is working
curl "https://api.telegram.org/bot<TOKEN>/getWebhookInfo"
You should see:
{
"ok": true,
"result": {
"url": "https://carkunda.yourdomain.com/webhook/...",
"has_custom_certificate": false,
"pending_update_count": 0,
"last_error_date": null
}
}
If pending_update_count is growing and last_error_message exists, something’s wrong with your server.
3.5 Bot Menu & Commands#
Setting commands via the API gives users a nice autocomplete menu:
curl -X POST "https://api.telegram.org/bot<TOKEN>/setMyCommands" \
-H "Content-Type: application/json" \
-d '{
"commands": [
{"command": "start", "description": "Welcome & main menu"},
{"command": "cars", "description": "Browse all cars"},
{"command": "brands", "description": "List all brands"},
{"command": "search", "description": "Search for a car"},
{"command": "ev", "description": "Electric vehicles"},
{"command": "compare", "description": "Compare two cars"},
{"command": "random", "description": "Random car pick"},
{"command": "wishlist", "description": "Your saved cars"},
{"command": "emi", "description": "EMI calculator"},
{"command": "subscribe", "description": "Price alerts"},
{"command": "help", "description": "Show all commands"}
]
}'
Part 4: Discord Bot — From Zero to Production#
4.1 Creating a Discord Application#
- Go to discord.com/developers/applications
- Click “New Application” → name it (e.g., “CarKunda”)
- Go to Bot tab → click “Add Bot”
- Copy the bot token (keep it secret!)
- Under Privileged Gateway Intents, enable Message Content Intent
- Go to OAuth2 → URL Generator:
- Scopes:
bot,applications.commands - Bot Permissions:
Send Messages,Embed Links,Attach Files,Use Slash Commands
- Scopes:
- Copy the generated URL and open it to invite the bot to your server
4.2 Building the Bot#
Project setup:
mkdir carkunda-discord && cd carkunda-discord
npm init -y
npm install discord.js mongodb
Full code — index.js:
const { Client, GatewayIntentBits, EmbedBuilder, ActionRowBuilder, ButtonBuilder,
ButtonStyle, AttachmentBuilder, SlashCommandBuilder, REST, Routes } = require('discord.js');
const { MongoClient } = require('mongodb');
const fs = require('fs');
const path = require('path');
// ===== CONFIG =====
const BOT_TOKEN = 'YOUR_DISCORD_BOT_TOKEN';
const MONGO_URI = 'mongodb://localhost:27017';
const DB_NAME = 'nepalcars';
const SITE_URL = 'https://cars.yourdomain.com';
const IMAGES_BASE = '/var/www/your-app/public/images/cars';
const CLIENT_ID = 'YOUR_APPLICATION_CLIENT_ID';
// ==================
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent]
});
let db;
async function connectDB() {
const mongo = new MongoClient(MONGO_URI);
await mongo.connect();
db = mongo.db(DB_NAME);
console.log('Connected to MongoDB');
}
Registering Slash Commands#
Discord uses slash commands — they’re like forms, not raw text. You register them via the REST API:
async function registerCommands() {
const commands = [
new SlashCommandBuilder().setName('cars').setDescription('Browse all cars available in Nepal'),
new SlashCommandBuilder().setName('brands').setDescription('List all car brands'),
new SlashCommandBuilder().setName('brand').setDescription('Browse cars by brand')
.addStringOption(opt => opt.setName('name').setDescription('Brand name').setRequired(true)),
new SlashCommandBuilder().setName('search').setDescription('Search for a car')
.addStringOption(opt => opt.setName('query').setDescription('Search query').setRequired(true)),
new SlashCommandBuilder().setName('price').setDescription('Filter cars by price range')
.addIntegerOption(opt => opt.setName('min').setDescription('Min price in lakhs').setRequired(true))
.addIntegerOption(opt => opt.setName('max').setDescription('Max price in lakhs').setRequired(false)),
new SlashCommandBuilder().setName('ev').setDescription('Show all electric vehicles'),
new SlashCommandBuilder().setName('compare').setDescription('Compare two cars')
.addStringOption(opt => opt.setName('car1').setDescription('First car name').setRequired(true))
.addStringOption(opt => opt.setName('car2').setDescription('Second car name').setRequired(true)),
new SlashCommandBuilder().setName('random').setDescription('Get a random car'),
new SlashCommandBuilder().setName('emi').setDescription('Calculate EMI')
.addNumberOption(opt => opt.setName('amount').setDescription('Loan amount in lakhs').setRequired(true))
.addNumberOption(opt => opt.setName('years').setDescription('Loan tenure in years').setRequired(true))
.addNumberOption(opt => opt.setName('rate').setDescription('Interest rate %').setRequired(true)),
new SlashCommandBuilder().setName('wishlist').setDescription('View your wishlist'),
];
const rest = new REST().setToken(BOT_TOKEN);
await rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands.map(c => c.toJSON()) });
console.log('Slash commands registered');
}
Rich Embeds & Attachments#
Discord’s equivalent of Telegram’s formatted messages are Embeds — rich cards with fields, colors, images:
function buildCarEmbed(car, detailed = false) {
const embed = new EmbedBuilder()
.setTitle(`🚗 ${car.name}`)
.setColor(car.type === 'EV' ? 0x22c55e : 0x71717a)
.setURL(`${SITE_URL}/cars/${car.slug}`)
.addFields(
{ name: '🏷️ Brand', value: car.brand || 'N/A', inline: true },
{ name: '💰 Price', value: car.price_display || formatPrice(car.price_min_npr), inline: true },
{ name: '🔧 Type', value: `${car.type || 'N/A'} | ${car.body_type || 'N/A'}`, inline: true },
);
if (car.power_hp) embed.addFields({ name: '⚡ Power', value: `${car.power_hp} HP`, inline: true });
if (detailed && car.pros?.length) {
embed.addFields({ name: '✅ Pros', value: car.pros.map(p => `• ${p}`).join('\n').substring(0, 1024) });
}
embed.setFooter({ text: 'CarKunda — Nepal\'s Car Guide' });
embed.setTimestamp();
return embed;
}
Sending Cars with Images#
Discord handles images as attachments, then you reference them in the embed:
async function sendCar(interaction, car, detailed = false, ephemeral = false) {
const embed = buildCarEmbed(car, detailed);
const buttons = new ActionRowBuilder().addComponents(
new ButtonBuilder().setLabel('View Online').setStyle(ButtonStyle.Link)
.setURL(`${SITE_URL}/cars/${car.slug}`).setEmoji('🌐'),
new ButtonBuilder().setCustomId(`detail_${car.slug}`).setLabel('Full Details')
.setStyle(ButtonStyle.Primary).setEmoji('📋'),
new ButtonBuilder().setCustomId(`wish_${car.slug}`).setLabel('Wishlist')
.setStyle(ButtonStyle.Secondary).setEmoji('❤️'),
);
const files = [];
const imgPath = getImagePath(car);
if (imgPath) {
const filename = `${car.slug}.jpg`;
files.push(new AttachmentBuilder(imgPath, { name: filename }));
embed.setImage(`attachment://${filename}`);
}
if (interaction.deferred || interaction.replied) {
await interaction.followUp({ embeds: [embed], components: [buttons], files, ephemeral });
} else {
await interaction.reply({ embeds: [embed], components: [buttons], files, ephemeral });
}
}
Handling Interactions (Commands + Buttons)#
client.on('interactionCreate', async (interaction) => {
// Button clicks
if (interaction.isButton()) {
if (interaction.customId.startsWith('detail_')) {
const slug = interaction.customId.replace('detail_', '');
const car = await db.collection('cars').findOne({ slug });
if (car) await sendCar(interaction, car, true, true); // ephemeral = only visible to clicker
}
if (interaction.customId.startsWith('wish_')) {
const slug = interaction.customId.replace('wish_', '');
const userId = interaction.user.id;
// Toggle wishlist
const user = await db.collection('discord_users').findOne({ user_id: userId });
if (user?.wishlist?.includes(slug)) {
await db.collection('discord_users').updateOne({ user_id: userId }, { $pull: { wishlist: slug } });
return interaction.reply({ content: `Removed from wishlist.`, ephemeral: true });
} else {
await db.collection('discord_users').updateOne(
{ user_id: userId },
{ $addToSet: { wishlist: slug }, $set: { username: interaction.user.username } },
{ upsert: true }
);
return interaction.reply({ content: `❤️ Added to wishlist!`, ephemeral: true });
}
}
return;
}
if (!interaction.isChatInputCommand()) return;
// /search
if (interaction.commandName === 'search') {
await interaction.deferReply(); // Shows "Bot is thinking..."
const query = interaction.options.getString('query');
const cars = await db.collection('cars').find({
$or: [{ name: new RegExp(query, 'i') }, { brand: new RegExp(query, 'i') }]
}).toArray();
if (!cars.length) return interaction.followUp(`No results for "${query}".`);
for (const car of cars.slice(0, 5)) {
await sendCar(interaction, car, true);
}
}
// /emi
else if (interaction.commandName === 'emi') {
const amount = interaction.options.getNumber('amount') * 100000;
const years = interaction.options.getNumber('years');
const rate = interaction.options.getNumber('rate');
const monthlyRate = rate / 12 / 100;
const months = years * 12;
const emi = amount * monthlyRate * Math.pow(1 + monthlyRate, months)
/ (Math.pow(1 + monthlyRate, months) - 1);
const embed = new EmbedBuilder()
.setTitle('📊 EMI Calculator')
.setColor(0x22c55e)
.addFields(
{ name: 'Loan Amount', value: `Rs. ${(amount/100000).toFixed(2)} Lakh`, inline: true },
{ name: '💰 Monthly EMI', value: `**Rs. ${Math.round(emi).toLocaleString()}**`, inline: true },
);
await interaction.reply({ embeds: [embed] });
}
});
Start the bot:
client.once('ready', () => {
console.log(`Logged in as ${client.user.tag}`);
client.user.setActivity('Nepal\'s Car Guide | /cars', { type: 3 });
});
connectDB().then(async () => {
await registerCommands();
await client.login(BOT_TOKEN);
});
4.3 Slash Commands Deep Dive#
Key differences from Telegram:
- Options are form fields — Users get autocomplete, type validation, required/optional fields. They don’t type raw text.
deferReply()— Discord requires a response within 3 seconds. If your DB query takes longer, calldeferReply()first, thenfollowUp()when ready.- Ephemeral responses —
{ ephemeral: true }makes the response visible only to the user who triggered it. Perfect for wishlists. - Global vs Guild commands — Global commands (what we use) take up to 1 hour to propagate. Guild-specific commands are instant but only work in that server.
// Global (slow propagation, works everywhere)
rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands });
// Guild-specific (instant, one server only)
rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), { body: commands });
Part 5: Database Integration#
Both bots share the same MongoDB database (nepalcars). Here’s how the collections are used:
cars — The main car listings:
{
slug: "hyundai-creta",
name: "Hyundai Creta",
brand: "Hyundai",
type: "ICE", // or "EV", "Hybrid"
body_type: "SUV",
price_min_npr: 4500000,
price_max_npr: 5200000,
price_display: "Rs. 45.00 - 52.00 Lakh",
power_hp: 115,
torque_nm: 144,
top_speed: 180,
seating: "5",
pros: ["Good ground clearance", "Feature loaded"],
cons: ["No diesel option", "Pricey top variant"],
images: ["/images/cars/hyundai-creta/1.jpg"],
// ...
}
bot_users (Telegram) / discord_users (Discord):
{
chat_id: 123456789, // Telegram chat ID (or user_id for Discord)
username: "john",
wishlist: ["hyundai-creta", "tata-nexon-ev"],
subscribed: true, // For price alerts
}
bot_meta — Stores price snapshots and car counts for change detection:
{
key: "price_snapshots",
prices: { "hyundai-creta": { min: 4500000, max: 5200000 }, ... },
updated: ISODate("2026-02-09")
}
Part 6: Deployment & Production#
PM2 Process Management#
# Start bots
pm2 start nepalcars-bot/index.js --name nepalcars-bot
pm2 start carkunda-discord/index.js --name carkunda-discord
# Save so they restart on reboot
pm2 save
pm2 startup # generates systemd script
# Useful commands
pm2 logs nepalcars-bot # watch logs
pm2 restart nepalcars-bot # restart after code changes
pm2 list # see all processes
Nginx Config#
Create /etc/nginx/sites-available/carkunda.yourdomain.com (the full config is shown in Section 3.4), then:
sudo ln -s /etc/nginx/sites-available/carkunda.yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t # test config
sudo systemctl reload nginx
SSL with Certbot#
# Make sure DNS points to your server first
# If using Cloudflare, temporarily set to "DNS only" (grey cloud)
sudo certbot --nginx -d carkunda.yourdomain.com
# Certbot auto-renews. Verify:
sudo certbot renew --dry-run
Cloudflare DNS Setup#
- Add an A record:
carkunda→YOUR_VPS_IP(your VPS IP) - Set proxy status to DNS only (grey cloud) for initial Certbot setup
- After SSL is working, you can enable the orange cloud (Cloudflare proxy) if desired
- If using Cloudflare proxy, set SSL mode to Full (strict) in Cloudflare dashboard
Monitoring#
# Check bot health endpoint
curl https://carkunda.yourdomain.com/health
# → {"status":"ok","uptime":12345.67}
# Check webhook status
curl "https://api.telegram.org/bot<TOKEN>/getWebhookInfo"
# Watch logs in real-time
pm2 logs --lines 50
Part 7: Adding Bot Info to Your Website#
If you have a website (like our Next.js car listings site), add CTA sections:
<div className="bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg p-8 text-white">
<h2>🤖 Try Our Bots!</h2>
<p>Get car info, compare prices, and set alerts — right from your chat app.</p>
<div className="flex gap-4 mt-4">
<a href="https://t.me/YourBotUsername" className="bg-white text-blue-600 px-6 py-3 rounded">
📱 Telegram Bot
</a>
<a href="https://discord.gg/your-invite" className="bg-white text-purple-600 px-6 py-3 rounded">
💬 Discord Server
</a>
</div>
</div>
Part 8: Tips & Common Issues#
Telegram Gotchas#
“Chat not found” error
The bot can’t message a user who hasn’t sent /start first. The user must initiate the conversation.
Webhook 526 error This means SSL certificate issues. If using Cloudflare:
- Set SSL mode to “Full (strict)”
- Make sure Certbot successfully issued a certificate
- Try setting Cloudflare DNS to “DNS only” (grey cloud) temporarily
Markdown escaping nightmares Telegram has two Markdown modes:
Markdown(v1): Escape_,*,`,[with backslashMarkdownV2: Escape almost EVERYTHING:_*[]()~>#+\-=|{}.!
We use v1 for sanity. Our escMd() function handles it:
function escMd(str) {
if (!str) return '';
return String(str).replace(/([_*`\[])/g, '\\$1');
}
Photos not sending
- Check the file path exists (
fs.existsSync) - Photo captions max 1024 characters — truncate!
- If sending by URL, it must be publicly accessible
Discord Gotchas#
Slash commands not showing up Global commands take up to 1 hour to propagate. For testing, use guild-specific commands (instant). After confirming they work, switch to global.
Slash command options are FORM FIELDS
Users don’t type /search toyota. They type /search and Discord shows a form field where they type “toyota”. You access it via:
interaction.options.getString('query') // not from message text!
interaction.options.getInteger('min')
interaction.options.getNumber('amount')
3-second response deadline
Discord expects a response within 3 seconds. For database queries, always deferReply() first:
await interaction.deferReply(); // Shows "Bot is thinking..."
// ... do slow work ...
await interaction.followUp({ embeds: [embed] }); // Send actual response
Part 9: What’s Next#
More Platforms#
- WhatsApp Business API — More complex setup, but massive reach in South Asia
- Viber — Similar to Telegram’s bot API
Monetization Ideas#
- Featured car listings (dealers pay to be highlighted)
- Lead generation (connect users with dealers)
- Premium alerts (instant price drop notifications)
Analytics#
- Track command usage per user
- Monitor popular searches
- A/B test different car card formats
- Track inline mode usage (which chats, how often)
Summary#
We built three bots in this guide:
| Bot | Platform | Features | Mode |
|---|---|---|---|
| CarKunda | Telegram | Full CRUD, images, inline, webhooks | Webhook |
| CarKunda | Discord | Slash commands, embeds, buttons | WebSocket |
The key takeaways: 2. Telegram is easier — Text commands, simple API, polling for development. 3. Discord is richer — Embeds, slash command forms, ephemeral responses. 4. Share your database — Both bots hit the same MongoDB. Write once, serve everywhere. 5. PM2 is essential — Auto-restart on crash, log management, process monitoring. 6. Webhooks for production — More efficient than polling, but need SSL.
Happy bot building! 🤖