Pendahuluan

Pada tutorial ini, saya akan membangun bot Telegram sederhana yang mendukung operasi CRUD (Create, Read, Update, Delete). Studi kasusnya adalah manajemen data item lewat chat Telegram, sehingga kita bisa menambah, melihat, mengubah, dan menghapus data langsung dari bot.

Pondasi proyek dan tujuan CRUD bot Telegram

Saya menggunakan Node.js dan library Telegraf karena setup-nya ringan, dokumentasinya matang, dan cocok untuk membuat bot dengan command berbasis teks. Agar fokus ke konsep CRUD, data disimpan dulu dalam file JSON lokal. Pendekatan ini cocok untuk belajar alur dasar sebelum pindah ke database seperti PostgreSQL atau MongoDB.

Hasil akhir yang akan dicapai:
  • Bot bisa menerima command create data
  • Bot bisa menampilkan semua data dan data per ID
  • Bot bisa update data berdasarkan ID
  • Bot bisa delete data berdasarkan ID
  • Semua perubahan data tersimpan secara persisten di file lokal
Prasyarat:
  • Memiliki domain atau sub-domain
  • Memiliki akses ke VPS dengan akses sudo
  • Sudah install Node.js versi LTS, npm, telegraf
  • Sudah memiliki bot token dari BotFather
  • Sudah menyiapkan project bot Telegram CRUD di komputer lokal

Instalasi dependensi

1. Lakukan update:
Ubuntu:

sudo apt update && sudo apt upgrade -y

Rocky Linux:

sudo dnf update -y

2. Install nodejs:
Ubuntu:

sudo apt install nodejs -y

Rocky Linux:

sudo dnf install nodejs -y

3. Update npm:
Ubuntu:

sudo npm install -g npm@latest

Rocky Linux:

sudo npm install -g npm@latest

4. Install telegraf:
Ubuntu:

npm install telegraf

Rocky Linux:

npm install telegraf

Struktur folder

bot
├── .env
├── index.js
└── data.json

.env:

BOT_TOKEN=ISI_BOT_TOKEN_ANDA_DISINI

data.json:

[]

index.js:

require("dotenv").config();

const fs = require("node:fs");
const path = require("node:path");
const { Telegraf, Markup } = require("telegraf");

const DATA_FILE = path.join(__dirname, "data.json");
const token = process.env.BOT_TOKEN;

if (!token) {
    throw new Error("BOT_TOKEN belum diatur. Silakan isi file .env terlebih dahulu.");
}

function ensureDataFile() {
    if (!fs.existsSync(DATA_FILE)) {
        fs.writeFileSync(DATA_FILE, '{"users":{}}', "utf8");
    }
}

function normalizeStore(parsed) {
    let store = { users: {} };

    if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && parsed.users) {
        store = parsed;
    } else if (Array.isArray(parsed)) {
        const lastId =
            parsed.length === 0 ? 0 : Math.max(...parsed.map((item) => Number(item.id) || 0));
        store = {
            users: {
                legacy: {
                    lastId,
                    items: parsed
                }
            }
        };
    }

    // Migrasi otomatis: Ubah kunci ID numerik (lama) ke format u_ID (baru)
    for (const key in store.users) {
        if (key !== "legacy" && !key.startsWith("u_") && !isNaN(key)) {
            store.users[`u_${key}`] = store.users[key];
            delete store.users[key];
        }
    }

    return store;
}

function readStore() {
    ensureDataFile();
    const raw = fs.readFileSync(DATA_FILE, "utf8");

    try {
        const parsed = JSON.parse(raw);
        return normalizeStore(parsed);
    } catch {
        return { users: {} };
    }
}

function writeStore(store) {
    fs.writeFileSync(DATA_FILE, JSON.stringify(store, null, 2), "utf8");
}

function getUserBucket(store, userId) {
    if (!userId) return { lastId: 0, items: [] };

    // Gunakan prefix 'u_' untuk membedakan ID user murni dengan kunci internal seperti 'legacy'
    const key = `u_${userId}`;
    if (!store.users[key]) {
        store.users[key] = {
            lastId: 0,
            items: []
        };
    }

    return store.users[key];
}

function createItem(userId, name, description) {
    const store = readStore();
    const bucket = getUserBucket(store, userId);
    const nextId = Number(bucket.lastId || 0) + 1;

    const newItem = {
        id: nextId,
        name,
        description,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString()
    };

    bucket.lastId = nextId;
    bucket.items.push(newItem);
    writeStore(store);
    return newItem;
}

function getAllItems(userId) {
    const store = readStore();
    const bucket = getUserBucket(store, userId);
    return bucket.items;
}

function getItemById(userId, id) {
    const store = readStore();
    const bucket = getUserBucket(store, userId);
    return bucket.items.find((item) => Number(item.id) === Number(id)) || null;
}

function updateItem(userId, id, name, description) {
    const store = readStore();
    const bucket = getUserBucket(store, userId);
    const index = bucket.items.findIndex((item) => Number(item.id) === Number(id));

    if (index === -1) {
        return null;
    }

    bucket.items[index] = {
        ...bucket.items[index],
        name,
        description,
        updatedAt: new Date().toISOString()
    };

    writeStore(store);
    return bucket.items[index];
}

function deleteItem(userId, id) {
    const store = readStore();
    const bucket = getUserBucket(store, userId);
    const index = bucket.items.findIndex((item) => Number(item.id) === Number(id));

    if (index === -1) {
        return false;
    }

    bucket.items.splice(index, 1);
    writeStore(store);
    return true;
}

const bot = new Telegraf(token);
const userStates = new Map();
const PAGE_SIZE = 5; // Ukuran halaman lebih kecil agar nyaman di layar HP

function mainMenuKeyboard() {
    return Markup.inlineKeyboard([
        [Markup.button.callback("Create", "action:create")],
        [Markup.button.callback("Read Semua", "action:read_all")],
        [Markup.button.callback("Cari ID", "action:read_id")],
        [Markup.button.callback("Ubah", "action:update"), Markup.button.callback("Hapus", "action:delete")]
    ]);
}

function paginationKeyboard(page, totalPages) {
    const buttons = [];
    if (page > 1) buttons.push(Markup.button.callback("⬅️ Prev", `page:${page - 1}`));
    if (page < totalPages) buttons.push(Markup.button.callback("Next ➡️", `page:${page + 1}`));

    return Markup.inlineKeyboard([
        buttons,
        [Markup.button.callback("🏠 Menu Utama", "action:menu")]
    ]);
}

function buildReadAllView(userId, page = 1) {
    const items = getAllItems(userId);
    if (items.length === 0) {
        return { text: "Data masih kosong.", keyboard: mainMenuKeyboard() };
    }

    const totalPages = Math.ceil(items.length / PAGE_SIZE);
    const safePage = Math.min(Math.max(page, 1), totalPages);
    const start = (safePage - 1) * PAGE_SIZE;
    const currentItems = items.slice(start, start + PAGE_SIZE);

    const list = currentItems
        .map((item) => `🔹 *ID: ${item.id}* - ${item.name}`)
        .join("\n");

    return {
        text: `📂 *Daftar Data (Hal: ${safePage}/${totalPages})*\n\n${list}\n\n_Pilih ID untuk detail menggunakan menu Cari ID._`,
        keyboard: paginationKeyboard(safePage, totalPages)
    };
}

function confirmDeleteKeyboard() {
    return Markup.inlineKeyboard([
        [
            Markup.button.callback("Ya, Hapus", "action:confirm_delete"),
            Markup.button.callback("Batal", "action:cancel")
        ]
    ]);
}

function formatItem(item) {
    return `ID: ${item.id}\nNama: ${item.name}\nDeskripsi: ${item.description}`;
}

function getUserId(ctx) {
    return ctx.from?.id;
}

async function showMainMenu(ctx, message = "Silakan pilih aksi CRUD:") {
    const keyboard = mainMenuKeyboard();
    if (ctx.callbackQuery) {
        return ctx.editMessageText(message, {
            reply_markup: keyboard.reply_markup,
            parse_mode: "Markdown"
        });
    }
    await ctx.reply(message, { reply_markup: keyboard.reply_markup, parse_mode: "Markdown" });
}

bot.start((ctx) => {
    const userId = getUserId(ctx);
    if (userId) {
        userStates.delete(userId);
    }

    return showMainMenu(ctx, "Halo! Saya bot CRUD. Gunakan tombol di bawah ini.");
});

bot.action("action:menu", async (ctx) => {
    const userId = getUserId(ctx);
    if (userId) userStates.delete(userId);
    await ctx.answerCbQuery();
    return showMainMenu(ctx);
});

bot.action("action:create", async (ctx) => {
    const userId = getUserId(ctx);
    if (userId) {
        userStates.set(userId, { action: "create", step: "name" });
    }

    await ctx.answerCbQuery();
    await ctx.reply("📝 *Buat Data*\nMasukkan nama item:", { parse_mode: "Markdown" });
});

bot.action("action:read_all", async (ctx) => {
    const userId = getUserId(ctx);
    if (!userId) return;

    await ctx.answerCbQuery();
    const view = buildReadAllView(userId, 1);
    await ctx.editMessageText(view.text, {
        reply_markup: view.keyboard.reply_markup,
        parse_mode: "Markdown"
    });
});

bot.action(/page:(\d+)/, async (ctx) => {
    const userId = getUserId(ctx);
    const page = parseInt(ctx.match[1]);

    await ctx.answerCbQuery();
    const view = buildReadAllView(userId, page);
    await ctx.editMessageText(view.text, {
        reply_markup: view.keyboard.reply_markup,
        parse_mode: "Markdown"
    });
});

bot.action("action:read_id", async (ctx) => {
    const userId = getUserId(ctx);
    if (userId) {
        userStates.set(userId, { action: "read_id", step: "id" });
    }

    await ctx.answerCbQuery();
    await ctx.reply("Masukkan ID item yang ingin dilihat:");
});

bot.action("action:update", async (ctx) => {
    const userId = getUserId(ctx);
    if (userId) {
        userStates.set(userId, { action: "update", step: "id" });
    }

    await ctx.answerCbQuery();
    await ctx.reply("Masukkan ID item yang ingin diupdate:");
});

bot.action("action:delete", async (ctx) => {
    const userId = getUserId(ctx);
    if (userId) {
        userStates.set(userId, { action: "delete", step: "id" });
    }

    await ctx.answerCbQuery();
    await ctx.reply("Masukkan ID item yang ingin dihapus:");
});

bot.action("action:confirm_delete", async (ctx) => {
    const userId = getUserId(ctx);
    const state = userStates.get(userId);

    if (!state || state.action !== "delete" || !state.id) {
        await ctx.answerCbQuery("Sesi kadaluarsa.");
        return showMainMenu(ctx);
    }

    const success = deleteItem(userId, state.id);
    userStates.delete(userId);

    await ctx.answerCbQuery("Data dihapus.");
    await ctx.reply(`Data dengan ID ${state.id} berhasil dihapus.`);
    return showMainMenu(ctx);
});

bot.action("action:cancel", async (ctx) => {
    const userId = getUserId(ctx);
    if (userId) userStates.delete(userId);

    await ctx.answerCbQuery("Dibatalkan.");
    await ctx.reply("Aksi dibatalkan.");
    return showMainMenu(ctx);
});

bot.on("text", async (ctx) => {
    const userId = getUserId(ctx);
    if (!userId) {
        return;
    }

    const state = userStates.get(userId);
    const text = (ctx.message?.text || "").trim();

    if (!state) {
        if (text.startsWith("/") && text !== "/start") {
            return ctx.reply("Gunakan /start lalu pilih tombol menu.");
        }
        return;
    }

    if (state.action === "create") {
        if (state.step === "name") {
            if (!text) {
                return ctx.reply("Nama item tidak boleh kosong. Masukkan nama item:");
            }

            userStates.set(userId, {
                action: "create",
                step: "description",
                name: text
            });
            return ctx.reply("Masukkan deskripsi item:");
        }

        if (state.step === "description") {
            if (!text) {
                return ctx.reply("Deskripsi item tidak boleh kosong. Masukkan deskripsi item:");
            }

            const item = createItem(userId, state.name, text);
            userStates.delete(userId);
            await ctx.reply(`Data telah disimpan.\n\nID: ${item.id}\nNama: ${item.name}\nDeskripsi: ${item.description}`);
            return showMainMenu(ctx);
        }
    }

    if (state.action === "read_id" && state.step === "id") {
        const targetId = Number(text);
        const item = isNaN(targetId) ? null : getItemById(userId, targetId);
        userStates.delete(userId);

        if (!item) {
            await ctx.reply("Data tidak ditemukan.");
            return showMainMenu(ctx);
        }

        await ctx.reply(formatItem(item));
        return showMainMenu(ctx);
    }

    if (state.action === "update") {
        if (state.step === "id") {
            const targetId = Number(text);
            const item = isNaN(targetId) ? null : getItemById(userId, targetId);
            if (!item) {
                return ctx.reply("ID tidak ditemukan. Masukkan ID item yang valid:");
            }

            userStates.set(userId, {
                action: "update",
                step: "name",
                id: Number(text)
            });
            return ctx.reply("Masukkan nama baru:");
        }

        if (state.step === "name") {
            if (!text) {
                return ctx.reply("Nama baru tidak boleh kosong. Masukkan nama baru:");
            }

            userStates.set(userId, {
                action: "update",
                step: "description",
                id: state.id,
                name: text
            });
            return ctx.reply("Masukkan deskripsi baru:");
        }

        if (state.step === "description") {
            if (!text) {
                return ctx.reply("Deskripsi baru tidak boleh kosong. Masukkan deskripsi baru:");
            }

            const updated = updateItem(userId, state.id, state.name, text);
            userStates.delete(userId);

            if (!updated) {
                await ctx.reply("Data tidak ditemukan.");
                return showMainMenu(ctx);
            }

            await ctx.reply(
                `Data berhasil diperbarui.\n\nID: ${updated.id}\nNama: ${updated.name}\nDeskripsi: ${updated.description}`
            );
            return showMainMenu(ctx);
        }
    }

    if (state.action === "delete" && state.step === "id") {
        const targetId = Number(text);
        const item = isNaN(targetId) ? null : getItemById(userId, targetId);

        if (!item) {
            await ctx.reply("Data tidak ditemukan. Masukkan ID yang valid:");
            return;
        }

        userStates.set(userId, {
            action: "delete",
            step: "confirm",
            id: targetId
        });

        await ctx.reply(
            `Konfirmasi Hapus?\n\n${formatItem(item)}`,
            confirmDeleteKeyboard()
        );
        return;
    }

    userStates.delete(userId);
    await ctx.reply("Sesi input direset. Silakan mulai lagi dari menu.");
    return showMainMenu(ctx);
});

const useWebhook = Boolean(process.env.WEBHOOK_DOMAIN);
const port = Number(process.env.PORT) || 3000;

const launchOptions = useWebhook
    ? {
        webhook: {
            domain: process.env.WEBHOOK_DOMAIN,
            port: port,
        },
    }
    : {};

bot.launch(launchOptions)
    .then(() => {
        if (useWebhook) {
            console.log("🚀 Bot berjalan via WEBHOOK");
            console.log(`🔗 Domain: ${process.env.WEBHOOK_DOMAIN}`);
            console.log(`🔌 Port: ${port}`);
        } else {
            console.log("⚡ Bot berjalan via POLLING (Lokal)");
        }
    })
    .catch((err) => {
        console.error("❌ Gagal menjalankan bot:", err);
    });

process.once("SIGINT", () => bot.stop("SIGINT"));
process.once("SIGTERM", () => bot.stop("SIGTERM"));

Kesimpulan

Pada tutorial ini, kita berhasil membangun bot Telegram CRUD yang sederhana namun siap pakai untuk pemula. Dengan pendekatan satu file utama dan penyimpanan lokal di data.json, proses belajar jadi lebih fokus ke konsep inti: bagaimana data dibuat, dibaca, diperbarui, dan dihapus melalui interaksi chat.

Perubahan dari command ke tombol juga membuat pengalaman pengguna lebih profesional. Pengguna cukup memulai dengan /start, lalu seluruh alur CRUD bisa dijalankan lewat menu tombol dengan panduan input bertahap. Pendekatan ini mengurangi kesalahan input, memperjelas alur, dan membuat bot lebih nyaman digunakan.

Meski masih menggunakan file JSON, fondasi aplikasinya sudah kuat untuk dikembangkan lebih lanjut. Tahap berikut yang paling disarankan adalah menambahkan database (misalnya SQLite/MySQL/PostgreSQL), validasi data yang lebih ketat, serta fitur keamanan dan logging agar bot siap dipakai di skenario yang lebih besar.




Baca Juga



masyarakat
masyarakat

Mari Berbagi

Alamat

Jl. Rompok Raya
Palembang
Indonesia