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#

AspectTelegramDiscord
API StyleHTTP-based, simpleWebSocket + REST, more complex
CommandsText commands (/start) + inline keyboardsSlash commands (form-based UI)
Rich ContentPhotos + Markdown captionsEmbeds (rich cards) + attachments
DeploymentPolling OR webhooksWebSocket (always connected)
User DiscoveryAnyone can find your bot via @usernameUsers must be in your server
Inline ModeYes — use bot in any chatNo equivalent

Bottom line: Telegram is simpler to start with. Discord has richer UI components. We’ll build for both.

What We’ll Build#

  1. CarKunda Bot (Telegram) — Full-featured car info bot with MongoDB, images, webhooks, inline mode
  2. 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

If you’re using webhooks (recommended for production Telegram bots), you need HTTPS. The easiest setup:

  1. Buy a domain or use a subdomain (e.g., carkunda.yourdomain.com)
  2. Point it to your VPS IP via Cloudflare DNS (A record)
  3. 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#

  1. Go to discord.com/developers/applications
  2. Click “New Application” → name it (e.g., “CarKunda”)
  3. Go to Bot tab → click “Add Bot”
  4. Copy the bot token (keep it secret!)
  5. Under Privileged Gateway Intents, enable Message Content Intent
  6. Go to OAuth2 → URL Generator:
    • Scopes: bot, applications.commands
    • Bot Permissions: Send Messages, Embed Links, Attach Files, Use Slash Commands
  7. 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:

  1. Options are form fields — Users get autocomplete, type validation, required/optional fields. They don’t type raw text.
  2. deferReply() — Discord requires a response within 3 seconds. If your DB query takes longer, call deferReply() first, then followUp() when ready.
  3. Ephemeral responses{ ephemeral: true } makes the response visible only to the user who triggered it. Perfect for wishlists.
  4. 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#

  1. Add an A record: carkundaYOUR_VPS_IP (your VPS IP)
  2. Set proxy status to DNS only (grey cloud) for initial Certbot setup
  3. After SSL is working, you can enable the orange cloud (Cloudflare proxy) if desired
  4. 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 backslash
  • MarkdownV2: 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:

BotPlatformFeaturesMode
CarKundaTelegramFull CRUD, images, inline, webhooksWebhook
CarKundaDiscordSlash commands, embeds, buttonsWebSocket

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! 🤖