18 β Yakuniy kapston: to'liq bot¶
β¬ οΈ Oldingi: 17 β Production va deploy Β· π README Β· Keyingi: 19 β Guruhlarda ishlash β‘οΈ
Bu bobda: kitobning I-V qismlarida (01-17) o'rganganlarimizning hammasini bitta to'liq, ishlaydigan botda birlashtiramiz β "Tadbirga ro'yxatdan o'tkazuvchi bot". Foydalanuvchi
/royxatbilan ro'yxatdan o'tadi (ism -> telefon (kontakt yoki matn) -> tadbirni inline tugma bilan tanlash -> tasdiq), arizasibetter-sqlite3bazasiga saqlanadi;/meningbilan o'z arizalarini ko'radi; admin esa/adminbilan statistikani va/broadcastbilan ro'yxatdan o'tganlarga ommaviy xabarni yuboradi. Yo'l-yo'lakay biz loyihani modullarga ajratamiz (11-bob), conversation forma quramiz (08-bob), DB repository yozamiz (10-bob), klaviatura va callback ishlatamiz (06-07), middleware (logging + admin tekshirish) ulaymiz (09-bob), xato chegarasini o'rnatamiz (16-bob) va deploy variantlarini (13/17-bob) eslaymiz. Bu bob β kitobning birinchi yarmining yakuniy "loyiha"si; har bir bo'lakni qaysi bobda batafsil ko'rganingizga havola beramiz.Halollik eslatmasi: botning butun mantig'i β ro'yxatdan o'tish suhbati (
form.text/waitUntil/waitForCallbackQuery/external), DB'gaINSERTva o'qish (better-sqlite3),/meningro'yxat, admin statistika (COUNT/GROUP BY),/broadcast(distinct foydalanuvchilargactx.api.sendMessage), inline "Bekor" va tashqi/bekor(ctx.conversation.exit) β uchma-uch offline ishga tushirib tasdiqlangan: soxtaUpdate'larnibot.handleUpdate(...)ga uzatib, chiqayotgan API chaqiruvlarini transformer bilan ushlab, suhbat ichki konteksti uchun transformer'niconversations({ plugins: [...] })orqali ulab. Test DB temp faylda yaratilib, oxirida o'chiriladi. Natija: 9/9 PASS β bob oxiridagi hisobotda. Jonli ishlash β@BotFathertoken bilan polling/webhook orqali xabar yuborish β internet talab qiladi; deploy bo'limidagirun/pm2/Docker buyruqlari "illustrativ" deb belgilangan.
Nimani quramiz?¶
Tasavvur qiling, siz konferensiya yoki ustaxona uyushtiryapsiz va odamlarni Telegram orqali ro'yxatga olmoqchisiz. Botimizning vazifalari:
- Foydalanuvchi tomoni:
/startβ tanishtirish va yo'riqnoma./royxatβ ko'p qadamli forma: ism -> telefon -> tadbir -> tasdiq. Natija bazaga saqlanadi./meningβ foydalanuvchining o'z arizalari ro'yxati.- Admin tomoni (faqat
.envdagiADMIN_IDSro'yxatidagilar): /adminβ umumiy statistika: nechta ariza, har tadbir bo'yicha taqsimot./broadcast <xabar>β ro'yxatdan o'tgan har bir (takrorlanmas) foydalanuvchiga e'lon.
Bu loyiha kichik, lekin u "haqiqiy" botdir: unda forma, ma'lumotlar bazasi, ruxsat, klaviaturalar va admin-paneli bor β ya'ni amaliy botlarning 80% ana shu ustunlarga tayanadi.
Eslatma β nega aynan shu loyiha? "Ro'yxatdan o'tkazuvchi bot" β eng keng tarqalgan amaliy bot turi (tadbir, kurs, navbat, kichik buyurtma/booking β hammasi shu naqshda). Agar siz buni qurib chiqsangiz, restoran-buyurtma yoki navbat botini ham xuddi shu skelet bilan yozasiz: faqat forma maydonlari va jadval ustunlari o'zgaradi.
Loyiha tuzilishi¶
11-bobda ko'rgan modul tuzilishimizni shu loyihaga moslaymiz. Har papka bitta vazifaga javob beradi:
tadbir-bot/
.env # BOT_TOKEN, ADMIN_IDS β maxfiy, git'ga EMAS
.env.example # namuna (git'ga ha)
package.json # "type": "module"
data/
bot.db # SQLite fayli β .gitignore'ga qo'shing
src/
config.js # .env o'qish + validatsiya + EVENTS ro'yxati
bot.js # kirish nuqtasi: modullarni ulaydi, runner ishga tushiradi
services/
db.js # better-sqlite3 ulanish + jadval yaratish
registrations.js # repository: add / listByUser / total / perEvent / userIds
middlewares/
logging.js # har update'ni yozadi
inject.js # ctx.repo va ctx.isAdmin ni o'rnatadi (DI)
keyboards/
events.js # tadbir inline menyusi, kontakt tugma
conversations/
register.js # ro'yxatdan o'tish suhbati
handlers/
user.js # /start /royxat /mening (Composer)
admin.js # /admin /broadcast (Composer)
utils/
format.js # eventTitle va matn formatlash
Eslatma: quyida har modulni alohida fayl sifatida ko'rsatamiz, lekin o'qish uchun to'liq β hech narsa "qisqartirilmaydi". Agar xohlasangiz, hammasini bitta faylga ham yozishingiz mumkin; modullarga ajratish faqat loyiha o'sganda topish va o'zgartirishni osonlashtiradi (11-bobdagi sabablar). Bu bobning offline testi aynan shu kodni bitta faylga yig'ib ishga tushiradi.
1) Config β .env va tadbirlar ro'yxati¶
Avval markaziy konfiguratsiya. .env dan tokenni va admin ID'larni o'qiymiz hamda validatsiya qilamiz (11/17-bobdagi qoida: token yo'q bo'lsa, bot ishga tushmasin, jim o'tirib qolmasin).
// src/config.js
import "dotenv/config"; // .env ni process.env ga yuklaydi (npm i dotenv)
const token = process.env.BOT_TOKEN;
if (!token) {
throw new Error("BOT_TOKEN yo'q! .env faylga BOT_TOKEN=... yozing (02-bob).");
}
// ADMIN_IDS="111,222" -> [111, 222]
const adminIds = (process.env.ADMIN_IDS ?? "")
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => Number.isInteger(n) && n > 0);
// Tadbirlar β bu yerda qattiq kodlangan; xohlasangiz DB'dan ham o'qishingiz mumkin
export const EVENTS = [
{ id: "js", title: "JavaScript intensiv" },
{ id: "py", title: "Python bootcamp" },
{ id: "go", title: "Golang seminari" },
];
export const config = {
token,
adminIds,
dbPath: process.env.DB_PATH ?? "data/bot.db",
};
# .env.example
BOT_TOKEN=123456:ABC-DEF... # @BotFather'dan (02-bob)
ADMIN_IDS=111111111 # vergul bilan ko'p admin: 111,222
DB_PATH=data/bot.db
Diqqat:
.envni hech qachon git'ga qo'shmang (.gitignorega.envvadata/ni yozing). Tokenni kodga yozish β 02-bobda aytganimizdek, eng keng tarqalgan xavfsizlik xatosi. Token sizib ketsa,@BotFatherda/revokeqiling.
2) Ma'lumotlar bazasi qatlami¶
10-bobdagi better-sqlite3 va repository naqshini ishlatamiz. Avval ulanish va jadval:
// src/services/db.js
import Database from "better-sqlite3";
import { config } from "../config.js";
export const db = new Database(config.dbPath);
db.pragma("journal_mode = WAL"); // bir vaqtda o'qish/yozish uchun tezroq
db.exec(`
CREATE TABLE IF NOT EXISTS registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
full_name TEXT NOT NULL,
phone TEXT NOT NULL,
event TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
Endi repository β bazaga kirishni bitta modulga to'playmiz (handler'lar SQL bilan to'g'ridan-to'g'ri ishlamaydi). Bu 10-bobning asosiy g'oyasi: SQL bir joyda, qolgan kod faqat funksiya chaqiradi.
// src/services/registrations.js
import { db } from "./db.js";
// prepared statement'lar β bir marta tayyorlanadi, ko'p marta ishlatiladi
const stmts = {
insert: db.prepare(
`INSERT INTO registrations (user_id, full_name, phone, event)
VALUES (@user_id, @full_name, @phone, @event)`
),
byUser: db.prepare("SELECT * FROM registrations WHERE user_id = ? ORDER BY id"),
count: db.prepare("SELECT COUNT(*) AS n FROM registrations"),
countByEvent: db.prepare(
"SELECT event, COUNT(*) AS n FROM registrations GROUP BY event ORDER BY n DESC, event"
),
distinctUsers: db.prepare("SELECT DISTINCT user_id FROM registrations"),
};
export const registrations = {
add(reg) {
// reg = { user_id, full_name, phone, event }
return stmts.insert.run(reg).lastInsertRowid; // yangi ariza ID'si
},
listByUser(userId) {
return stmts.byUser.all(userId);
},
total() {
return stmts.count.get().n;
},
perEvent() {
return stmts.countByEvent.all(); // [{ event, n }, ...]
},
userIds() {
return stmts.distinctUsers.all().map((r) => r.user_id);
},
};
Eslatma β
better-sqlite3sinxron. 10-bobda ko'rganimizdek,.run(),.get(),.all()darhol natija qaytaradi βawaitkerak emas. Bu kichik-o'rta botlar uchun ideal: kod sodda, tez. Yuk ko'p bo'lsa yoki PostgreSQL/MySQL kerak bo'lsa, asinxron drayverga o'tasiz β SQL asoslari uchun ../sql/README.md.INSERT ... AUTOINCREMENTvaGROUP BYβ o'sha kitobdagi tushunchalar.
Kichik yordamchi β tadbir ID'sini chiroyli nomga aylantirish:
// src/utils/format.js
import { EVENTS } from "../config.js";
export const eventTitle = (id) => EVENTS.find((e) => e.id === id)?.title ?? id;
3) Klaviaturalar¶
06-07-boblardagi quruvchilarni alohida modulga ajratamiz. Tadbirlarni inline tugmalar sifatida ko'rsatamiz (callback data = ev:<id>), telefon uchun esa requestContact tugmasi.
// src/keyboards/events.js
import { InlineKeyboard, Keyboard } from "grammy";
import { EVENTS } from "../config.js";
// Tadbirlar inline menyusi: har tadbir alohida qatorda
export function eventsKeyboard() {
const kb = new InlineKeyboard();
for (const e of EVENTS) kb.text(e.title, `ev:${e.id}`).row();
return kb;
}
// Telefon so'rash: kontakt tugmasi (foydalanuvchi matn ham yozishi mumkin)
export const phoneKeyboard = new Keyboard()
.requestContact("Raqamni ulashish")
.resized()
.oneTime();
// Tasdiq tugmalari
export const confirmKeyboard = new InlineKeyboard()
.text("Tasdiqlash", "ok")
.text("Bekor", "cancel");
Eslatma:
requestContact(text)β foydalanuvchi bossa, Telegram uning telefon raqaminimessage.contactichida yuboradi (06-bob). Lekin ba'zilar tugmani bosmay, raqamni qo'lda yozadi β shuning uchun suhbatda ikkalasini ham qabul qilamiz.
4) Ro'yxatdan o'tish suhbati (conversation)¶
Botning yuragi β 08-bobdagi @grammyjs/conversations v2 bilan yozilgan ko'p qadamli forma. Eslang: suhbat funksiyasi replay modelida ishlaydi, shuning uchun har qanday side-effect (DB yozish) conversation.external ichida bo'lishi shart.
// src/conversations/register.js
import { EVENTS } from "../config.js";
import { eventTitle } from "../utils/format.js";
import { eventsKeyboard, phoneKeyboard, confirmKeyboard } from "../keyboards/events.js";
import { registrations } from "../services/registrations.js";
export async function royxat(conversation, ctx) {
// --- 1) Ism (oddiy matn, validatsiya bilan) ---
await ctx.reply("Ro'yxatdan o'tamiz. To'liq ismingizni yozing:");
const fullName = await conversation.form.text({
otherwise: (ctx) => ctx.reply("Iltimos, ismingizni matn bilan yuboring."),
});
// --- 2) Telefon: kontakt YOKI matn ---
await ctx.reply("Telefon raqamingizni yuboring (tugma orqali yoki matn):", {
reply_markup: phoneKeyboard,
});
const phoneCtx = await conversation.waitUntil(
(ctx) => Boolean(ctx.message?.contact || ctx.message?.text),
{ otherwise: (ctx) => ctx.reply("Telefon raqam yoki kontakt yuboring.") }
);
const phone = phoneCtx.message.contact?.phone_number ?? phoneCtx.message.text;
// --- 3) Tadbir tanlash (inline tugma) ---
await ctx.reply("Qaysi tadbirga yozilasiz?", { reply_markup: eventsKeyboard() });
const evCtx = await conversation.waitForCallbackQuery(/^ev:(.+)$/, {
otherwise: (ctx) => ctx.answerCallbackQuery("Tugmalardan birini tanlang."),
});
await evCtx.answerCallbackQuery();
const eventId = evCtx.match[1]; // regexdagi (.+) guruh
// --- 4) Tasdiq ---
await ctx.reply(
`Tekshiring:\nIsm: ${fullName}\nTel: ${phone}\nTadbir: ${eventTitle(eventId)}`,
{ reply_markup: confirmKeyboard }
);
const confirm = await conversation.waitForCallbackQuery(["ok", "cancel"]);
await confirm.answerCallbackQuery();
if (confirm.match === "cancel") {
await ctx.reply("Bekor qilindi. Qayta urinish: /royxat", {
reply_markup: { remove_keyboard: true },
});
return; // suhbat tugaydi, DB'ga hech narsa yozilmaydi
}
// --- 5) Saqlash: SIDE EFFECT -> external ichida! ---
const id = await conversation.external(() =>
registrations.add({
user_id: ctx.from.id,
full_name: fullName,
phone,
event: eventId,
})
);
await ctx.reply(
`Rahmat, ${fullName}! Siz "${eventTitle(eventId)}" ga yozildingiz (ariza #${id}).`,
{ reply_markup: { remove_keyboard: true } }
);
}
Bu funksiyani diqqat bilan o'qing β u butun kitobning yarmini bir joyga jamlaydi:
form.text({ otherwise })(08-bob) β matn kelguncha kutadi; matn bo'lmasaotherwiseogohlantiradi.waitUntil(predikat, { otherwise })(08-bob) β istalgan shartni qondiruvchi update'ni kutish. Bu yerda "kontakt yoki matn" mantig'i uchun ishlatamiz βwaitFor("message:text")faqat matnni olardi, bizga kontakt ham kerak.waitForCallbackQuery(/regex/ yoki [data])(07-08-bob) β inline tugma bosilishini kutadi; regexli variantctx.matchga guruhni beradi.external(() => registrations.add(...))(08-bob, OLTIN QOIDA) β DB yozuvi side-effect, shuning uchun replay'da takrorlanmasligi uchunexternalichida. Test buni isbotlaydi: 5 ta update kelsa-da,addaynan bir marta chaqiriladi.remove_keyboard: true(06-bob) β yakunda reply-klaviaturani olib tashlaymiz.
Diqqat β
externaldan faqat oddiy qiymat qaytaring.registrations.add(...)lastInsertRowidβ oddiy son qaytaradi, demak seriyalanadi (08-bobdagi qoida). Agaraddbutun ORM obyektini qaytarsa, uniexternal'dan to'g'ridan-to'g'ri qaytarmang β faqatidni qaytaring.
5) Foydalanuvchi handlerlari¶
09-bobdagi Composer bilan foydalanuvchi buyruqlarini bitta modulga guruhlaymiz.
// src/handlers/user.js
import { Composer } from "grammy";
import { eventTitle } from "../utils/format.js";
export const userHandlers = new Composer();
userHandlers.command("start", (ctx) =>
ctx.reply(
"Salom! Bu β tadbirga ro'yxatdan o'tkazuvchi bot.\n" +
"/royxat β yozilish\n/mening β arizalaringiz"
)
);
// Suhbatga kirish nuqtasi (08-bob)
userHandlers.command("royxat", (ctx) => ctx.conversation.enter("royxat"));
userHandlers.command("mening", (ctx) => {
const rows = ctx.repo.listByUser(ctx.from.id); // ctx.repo β inject middleware'dan
if (rows.length === 0) {
return ctx.reply("Sizda hali ariza yo'q. /royxat bilan yoziling.");
}
const list = rows
.map((r) => `#${r.id} β ${eventTitle(r.event)} (${r.phone})`)
.join("\n");
return ctx.reply(`Sizning arizalaringiz:\n${list}`);
});
Eslatma β
ctx.repoqayerdan keldi? Biz repository'nictxga middleware orqali "in'eksiya" qilamiz (09-bobdagi "ctx ga property qo'shish" + 10-bobdagictx.usersnaqshi). Bu handler'larni baza modulidan bevosita import qilmaslik imkonini beradi β test va almashtirish osonlashadi. Buni quyidainject.jsda ko'ramiz.
6) Admin handlerlari¶
Admin buyruqlari β statistika va broadcast. Avval admin-darvozasi (09-bob): bu Composer ichidagi har bir handler faqat admin uchun ishlaydi.
// src/handlers/admin.js
import { Composer, GrammyError } from "grammy";
import { eventTitle } from "../utils/format.js";
export const adminHandlers = new Composer();
// Darvoza: admin bo'lmasa, jim o'tkazib yuboramiz (next() chaqirmaymiz)
adminHandlers.use(async (ctx, next) => {
if (ctx.isAdmin) await next();
// admin bo'lmasa hech narsa qilmaymiz -> /admin oddiy foydalanuvchiga "ko'rinmaydi"
});
adminHandlers.command("admin", (ctx) => {
const total = ctx.repo.total();
const per =
ctx.repo
.perEvent()
.map((r) => `${eventTitle(r.event)}: ${r.n}`)
.join("\n") || "β";
return ctx.reply(`Statistika\nJami arizalar: ${total}\n${per}`);
});
adminHandlers.command("broadcast", async (ctx) => {
const text = ctx.match; // "/broadcast" dan keyingi matn (04-bob)
if (!text) return ctx.reply("Foydalanish: /broadcast <xabar>");
const ids = ctx.repo.userIds(); // takrorlanmas foydalanuvchilar
let yuborildi = 0;
for (const id of ids) {
try {
await ctx.api.sendMessage(id, `[E'lon] ${text}`); // chat_id ni o'zimiz beramiz
yuborildi++;
} catch (e) {
// Foydalanuvchi botni bloklagan bo'lishi mumkin -> 403; broadcast davom etsin
if (!(e instanceof GrammyError)) throw e;
}
}
return ctx.reply(`Yuborildi: ${yuborildi} ta foydalanuvchiga.`);
});
Bu yerda bir nechta muhim nuqta bor:
ctx.match(04-bob) βbot.command("broadcast")da buyruqdan keyingi matnni beradi./broadcast Salom hammaga->ctx.match === "Salom hammaga".ctx.api.sendMessage(id, ...)(03-bob) βctx.replyjoriy chatga yuboradi, lekin broadcast'da boshqa chatlarga yuboramiz, shuning uchunchat_idni o'zimiz beramiz.try/catch+GrammyError(16-bob) β foydalanuvchi botni bloklagan bo'lsa, Telegram403qaytaradi (GrammyError). Biz buni yutib, broadcast'ni davom ettiramiz β bitta bloklagan odam tufayli butun e'lon to'xtamasligi kerak.
Anti-eskirish β jiddiy broadcast uchun. Bu yerdagi oddiy
forsikli kichik ro'yxatlar uchun yaxshi. Lekin minglab foydalanuvchi bo'lsa, Telegram'ning rate-limit'iga (taxminan soniyasiga ~30 xabar) tushasiz. Production'da 15-bobdagi@grammyjs/auto-retry(xato bo'lganda avtomatik qayta urinish) va@grammyjs/runnerbilan konkurensiyani, hamda navbat (throttling) g'oyasini ishlating. Bu yerda men plaginlarning aniq sozlamalarini ixtiro qilmayman β 15-bobga va grammy.dev hujjatiga qarang.
7) Middleware'lar: logging va in'eksiya¶
09-bobdagi ikki middleware: birinchisi har update'ni yozadi, ikkinchisi ctx.repo va ctx.isAdmin ni o'rnatadi.
// src/middlewares/logging.js
export async function logging(ctx, next) {
const id = ctx.update.update_id;
const text = ctx.message?.text ?? ctx.callbackQuery?.data ?? "(boshqa)";
console.log(`[${id}] ${ctx.from?.first_name ?? "?"}: ${text}`);
await next(); // bularsiz pastdagi hech narsa ishlamaydi!
}
// src/middlewares/inject.js
import { config } from "../config.js";
import { registrations } from "../services/registrations.js";
export async function inject(ctx, next) {
ctx.repo = registrations; // repository'ni handler'larga uzatamiz
ctx.isAdmin = ctx.from ? config.adminIds.includes(ctx.from.id) : false;
await next();
}
Eslatma β
ctx.role/ctx.repotiplash. Sof JS'dactx.repo = ...to'g'ridan-to'g'ri ishlaydi (09-bob). TypeScript ishlatsangiz, buni context flavor orqali tiplaysiz (type MyContext = Context & { repo: typeof registrations; isAdmin: boolean }). Bu kitob JS'da, shuning uchun oddiy yo'lni tanladik β TS faqat "qo'shimcha foyda".
8) Hammasini ulash: bot.js¶
Endi kirish nuqtasi β modullarni to'g'ri tartibda ulaymiz. Tartib hayotiy muhim (09-bobdagi qoida):
// src/bot.js
import { Bot, GrammyError, HttpError } from "grammy";
import { conversations, createConversation } from "@grammyjs/conversations";
import { run } from "@grammyjs/runner";
import { config } from "./config.js";
import { logging } from "./middlewares/logging.js";
import { inject } from "./middlewares/inject.js";
import { royxat } from "./conversations/register.js";
import { userHandlers } from "./handlers/user.js";
import { adminHandlers } from "./handlers/admin.js";
const bot = new Bot(config.token);
// 1) Umumiy middleware'lar β eng oldinda
bot.use(logging);
bot.use(inject); // ctx.repo, ctx.isAdmin
// 2) conversations dvigateli (createConversation'dan OLDIN)
bot.use(conversations());
// 3) /bekor β conversations'dan KEYIN, createConversation'dan OLDIN (08-bob gotcha'si)
bot.command("bekor", async (ctx) => {
await ctx.conversation.exit("royxat");
await ctx.reply("Bekor qilindi.", { reply_markup: { remove_keyboard: true } });
});
// 4) Suhbatni ro'yxatdan o'tkazamiz (nomini aniq beramiz)
bot.use(createConversation(royxat, "royxat"));
// 5) Handler modullari (Composer'lar)
bot.use(userHandlers);
bot.use(adminHandlers);
// 6) Global xato tuzog'i (16-bob)
bot.catch((err) => {
const e = err.error;
if (e instanceof GrammyError) console.error("Telegram xato:", e.description);
else if (e instanceof HttpError) console.error("Tarmoq xato:", e);
else console.error("Noma'lum xato:", e);
});
// 7) Ishga tushirish (runner bilan konkurensiya β 15/17-bob)
run(bot);
console.log("Bot ishga tushdi.");
Diqqat β tartib qoidalarini eslang (09-bob): -
logging/injecteng oldinda β ular barcha update'larga tegishli. -conversations()createConversation()dan oldin β aks holdactx.conversationundefined bo'ladi. -/bekorconversations()dan keyin,createConversation()dan oldin β shunda faol suhbat/bekormatnini "yutib" yubormaydi (08-bobdagi nozik gotcha). - Admin darvozasi faqatadminHandlersComposer ichida ishlaydi β butun botni yopmaydi.Illustrativ:
run(bot)(yokibot.start()) jonli polling boshlaydi β token + internet kerak. Botning butun mantig'i offline tasdiqlangan (bob oxiridagi hisobot), lekin Telegram bilan haqiqiy almashinuv tokenni talab qiladi.
9) Botni offline tekshirish¶
Endi eng muhim qism β kapston botning asosiy oqimini uchma-uch offline tekshiramiz, xuddi 09-10-16-boblardagi naqsh bilan: soxta Update'larni bot.handleUpdate(...) ga uzatib, chiqayotgan API chaqiruvlarini transformer bilan ushlab. conversations ichidagi kontekstga transformer'ni plugins orqali ulaymiz (replay engine ichki Api quradi β 08-bob).
Quyida bitta faylga yig'ilgan test skeleti (yuqoridagi modullar bitta faylda):
// _verify_18.mjs (qisqartirilgan skelet β to'liq versiya probe muhitida ishga tushirilgan)
import { Bot, InlineKeyboard, Keyboard, Composer, GrammyError } from "grammy";
import { conversations, createConversation } from "@grammyjs/conversations";
import Database from "better-sqlite3";
import assert from "node:assert/strict";
function offlineTransformer(calls) {
return (prev, method, payload) => {
calls.push({ method, payload });
if (method === "sendMessage")
return Promise.resolve({ ok: true, result: { message_id: 1, date: 0,
chat: { id: payload.chat_id, type: "private" }, text: payload.text } });
return Promise.resolve({ ok: true, result: true });
};
}
function buildBot(repo, calls) {
const bot = new Bot("12345:FAKE-OFFLINE");
bot.botInfo = { id: 12345, is_bot: true, first_name: "EventBot", username: "event_bot",
can_join_groups: true, can_read_all_group_messages: false,
supports_inline_queries: false, can_connect_to_business: false, has_main_web_app: false };
const tr = offlineTransformer(calls);
bot.api.config.use(tr);
const installOffline = (ctx, next) => { ctx.api.config.use(tr); return next(); };
bot.use(async (ctx, next) => { ctx.repo = repo; await next(); });
bot.use(async (ctx, next) => { ctx.isAdmin = ctx.from?.id === 999; await next(); });
// MUHIM: conversations ichki ctx'siga ham transformer (plugins orqali)
bot.use(conversations({ plugins: [installOffline] }));
bot.command("bekor", async (ctx) => {
await ctx.conversation.exit("royxat");
await ctx.reply("Bekor qilindi.", { reply_markup: { remove_keyboard: true } });
});
bot.use(createConversation(buildRegisterConversation(repo), "royxat"));
// ... user + admin Composer'lar (yuqoridagidek) ...
return bot;
}
// To'liq oqim: ism -> kontakt -> tadbir -> tasdiq -> DB
const calls = [];
const bot = buildBot(repo, calls);
await bot.handleUpdate(mkText("/royxat", 777));
await bot.handleUpdate(mkText("Oqil Imomnazarov", 777));
await bot.handleUpdate(mkContact("+998901112233", 777));
await bot.handleUpdate(mkCb("ev:py", 777));
await bot.handleUpdate(mkCb("ok", 777));
assert.equal(repo.listByUser(777).length, 1); // DB'da aynan 1 ariza
assert.equal(repo.listByUser(777)[0].event, "py"); // to'g'ri tadbir
Soxta update yasovchilarni eslaylik (09-10-bobdan): buyruq update'iga entities:[{type:"bot_command",...}] shart, callback uchun callback_query.data, kontakt uchun message.contact:
function mkText(text, fromId = 777) {
const m = { message_id: 1, date: 0, text, chat: { id: fromId, type: "private" },
from: { id: fromId, is_bot: false, first_name: "Ali" } };
if (text.startsWith("/"))
m.entities = [{ type: "bot_command", offset: 0, length: text.split(/\s/)[0].length }];
return { update_id: Math.floor(Math.random() * 1e9), message: m };
}
function mkContact(phone, fromId = 777) {
return { update_id: Math.floor(Math.random() * 1e9), message: { message_id: 1, date: 0,
chat: { id: fromId, type: "private" }, from: { id: fromId, is_bot: false, first_name: "Ali" },
contact: { phone_number: phone, first_name: "Ali", user_id: fromId } } };
}
function mkCb(data, fromId = 777) {
return { update_id: Math.floor(Math.random() * 1e9), callback_query: { id: "c1",
from: { id: fromId, is_bot: false, first_name: "Ali" }, chat_instance: "ci", data,
message: { message_id: 1, date: 0, chat: { id: fromId, type: "private" } } } };
}
Bobning oxirida bu testlarning to'liq ro'yxati va natijasi (9/9 PASS) keltirilgan.
10) Deploy eslatmasi¶
Kapston tayyor bo'lgach, uni serverga chiqarish kerak (13/17-boblarni qisqacha eslaymiz):
Variant 1 β polling + pm2 (eng oddiy). VPS oling, kodni git clone qiling, npm ci, .env ni to'ldiring va:
npm i -g pm2
pm2 start src/bot.js --name tadbir-bot
pm2 save && pm2 startup # qayta yuklanganda avtomatik ishga tushadi
Variant 2 β Docker. Dockerfile bilan tasvir yig'ib, har joyda bir xil ishga tushirasiz:
FROM node:24-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "src/bot.js"]
Diqqat β
better-sqlite3va Docker.better-sqlite3nativ modul (C++ bilan kompilatsiya qilinadi). Docker'danpm cipaytida u qayta kompilyatsiya bo'lishi uchunpython3/make/g++(build-essential) kerak bo'lishi mumkin.node:24-slimda ular yo'q β yoapt-get install -y build-essential python3qo'shing, yoki to'liqnode:24image'idan foydalaning. Bu β men ko'p duch kelgan deploy tuzog'i.
Variant 3 β webhook (server bilan). Botingiz allaqachon HTTP server ishlatsa (masalan Mini App backend β 25-bob), polling o'rniga webhook qulayroq:
import { webhookCallback } from "grammy";
// app β Express/Hono ilovasi (13-bob)
app.use(webhookCallback(bot, "express"));
// bot.api.setWebhook("https://domeningiz.uz/bot");
Anti-eskirish: SQLite fayli (
data/bot.db) doimiy diskka yozilishi kerak. Ko'p PaaS (Heroku, ba'zi konteyner platformalari) "ephemeral" fayl tizimiga ega β qayta deploy'da fayl yo'qoladi. VPS yoki doimiy volume ishlating, yoki PostgreSQL'ga o'ting. Polling vs webhook tanlash, graceful shutdown va sirlarni boshqarish β 17-bobda to'liq.
Bob bo'yicha qisqacha xulosa¶
Biz butun kitobning birinchi yarmini bitta loyihada birlashtirdik:
| Bo'lak | Modul | Bob |
|---|---|---|
| Loyiha tuzilishi, config | config.js, papka daraxti |
11 |
| Conversation forma | conversations/register.js |
08 |
| DB + repository | services/db.js, registrations.js |
10 Β· SQL |
| Klaviatura + callback | keyboards/events.js, waitForCallbackQuery |
06 Β· 07 |
| Middleware (logging, inject) | middlewares/ |
09 |
| Admin + broadcast | handlers/admin.js |
04 Β· 15 |
| Xato boshqaruvi | bot.catch, try/catch |
16 |
| Deploy | pm2/Docker/webhook | 13 Β· 17 |
Python'da xuddi shu botni aiogram bilan yozsangiz, FSM (StatesGroup) + SQLAlchemy/sqlite3 + Router ishlatardingiz β taqqoslash uchun ../tgbot-python/README.md ga qarang. grammY yondashuvi (conversation = bitta uzluksiz funksiya) ko'pincha o'qish uchun qulayroq, lekin external qoidasini unutmaslik kerak.
Mashqlar¶
Quyidagi mashqlarning ko'pi offline tekshiriladi β kapston botni qurib, soxta
Update'larnibot.handleUpdate(...)ga uzatib, transformer (bot.api.config.use(...)) bilan chiqqan chaqiruvlarni yoki DB holatiniassertqiling. Suhbat testida transformer'niconversations({ plugins: [installOffline] })orqali ham ulang. Buyruq update'igaentities:[{type:"bot_command",...}]qo'shishni unutmang. Mashqlar β botni kengaytirish turida: mavjud kapstonga yangi imkoniyat qo'shasiz.
Oson¶
/helpbuyrug'i.userHandlersga/helpqo'shing: u barcha buyruqlar ro'yxatini chiqarsin. Offline:/helpuzatib, javobda"/royxat"borligini tasdiqlang.- Tadbir qo'shish.
config.jsdagiEVENTSga to'rtinchi tadbir ({ id: "rs", title: "Rust ustaxonasi" }) qo'shing.eventsKeyboard()uni avtomatik ko'rsatishini vaeventTitle("rs")to'g'ri nomni qaytarishini tekshiring. - Salomlashuvni boyitish.
/startda foydalanuvchining ismini ishlating:Salom, {ctx.from.first_name}!. Offline:/startuzatib, javobda ism borligini tasdiqlang. - Bo'sh ism tekshiruvi.
form.textotherwisexabarini "Ism kamida 2 harf bo'lsin" ga o'zgartiring vaconversation.form.texto'rniga shartli kutish bilan (waitUntilorqali matn uzunligini tekshirib) qayta yozing. Offline: bir harfli "A" rad etilsin, "Oqil" qabul qilinsin.
O'rta¶
- Arizani bekor qilish (o'chirish).
registrationsrepository'garemove(id, userId)qo'shing (DELETE FROM registrations WHERE id=? AND user_id=?).userHandlersga/ochirish <id>buyrug'i qo'shing (ctx.matchdan id). Offline: ariza yarating,/ochirish 1uzating,listByUserbo'sh ekanini tasdiqlang. (Faqat o'z arizasini o'chira olishini ham tekshiring.) - Tadbir limiti. Har tadbirga maksimal 2 ta odam yozilsin.
register.jsdaexternalichida limit tekshiruvi qo'shing: tadbir to'lgan bo'lsa, "Bu tadbir to'ldi" deb suhbatnireturnbilan tugating. Offline: bitta tadbirga 2 ta ariza yarating, 3-chisi rad etilishini tasdiqlang. - Admin: oxirgi arizalar.
adminHandlersga/oxirgiqo'shing: oxirgi 5 ta arizani ko'rsatsin (SELECT ... ORDER BY id DESC LIMIT 5). Offline: admin uchun ishlashini, oddiy foydalanuvchiga javob bermasligini tasdiqlang. /meningda bekor tugmasi./meningro'yxatidagi har bir arizaga inline "Bekor qilish" tugmasi (del:<id>) qo'shing vabot.callbackQuery(/^del:(\d+)$/, ...)bilan o'chiring (07-bob). Offline: ariza yarating,del:1callback uzating, o'chganini tasdiqlang.
Qiyin¶
- To'liq oqim regressiya testi. Bobdagi
royxatsuhbatini to'liq qurib, ketma-ketlikni offline o'tkazing:/royxat -> "Oqil" -> kontakt -> ev:py -> ok. So'ngrepo.listByUseraynan 1 yozuv ekanini,event === "py",phoneto'g'ri ekanini vaexternal(DB yozuvi) aynan bir marta ishlaganini (add'ni o'rab sanab) assert qiling. - Ikki tilli interfeys. Foydalanuvchi tilini sessiyada (10-bob) saqlang:
/til-> Uzbekcha/Inglizcha tanlash. Suhbat va handler matnlari tanlangan tilga qarab chiqsin (oddiy{ uz, en }lug'at bilan). Offline: tilnienga o'rnatib,/startinglizcha javob berishini tasdiqlang. - Admin darvozasi + errorBoundary. Admin Composer'ni
bot.errorBoundary(...)ichiga oling (09-bob): admin handleri xato bersa, bot qolgan qismida ishlashda davom etsin. Offline:/adminataylabthrowqilsin, xato chegarada ushlanib, keyingi oddiy/starthamon ishlashini tasdiqlang. - Tasdiqdan keyin tahrirlash. Tasdiq xabaridagi inline klaviaturani bosilgandan keyin
editMessageReplyMarkupbilan olib tashlang (07-bob) β foydalanuvchi qayta bosa olmasin. Offline:okcallback'dan keyineditMessageReplyMarkup(yokieditMessageText) chaqirilganini transformer orqali tasdiqlang. - Davomatni belgilash.
registrationsgaattended INTEGER DEFAULT 0ustuni qo'shing. Admin/keldi <ariza_id>bilan kelganni belgilasin./adminstatistikasiga "kelganlar: N" qatorini qo'shing. Offline: ariza yarating,/keldi 1(admin), statistikadakelganlar: 1chiqishini tasdiqlang.
Yechimlar
Quyidagi yechimlar bob oxiridagi
_verify_18.mjsnaqshi bilan offline ishga tushiriladi:buildBot(repo, calls)(transformer +botInfo+conversations({ plugins })),mkText/mkContact/mkCb, va temp DB. Qisqartirish uchun shu yordamchilar takrorlanmaydi β faqat o'zgartirilgan/qo'shilgan qism ko'rsatiladi.
1-mashq yechimi¶
userHandlers.command("help", (ctx) =>
ctx.reply("Buyruqlar:\n/royxat β yozilish\n/mening β arizalar\n/help β yordam")
);
// Offline: await bot.handleUpdate(mkText("/help", 777));
// assert.ok(sentTexts(calls)[0].includes("/royxat"));
Composer ga yangi command qo'shish β qolgan kodga tegmaymiz. Bu modullashning kuchi.
2-mashq yechimi¶
// config.js
export const EVENTS = [
{ id: "js", title: "JavaScript intensiv" },
{ id: "py", title: "Python bootcamp" },
{ id: "go", title: "Golang seminari" },
{ id: "rs", title: "Rust ustaxonasi" }, // yangi
];
// eventsKeyboard() EVENTS bo'yicha aylanadi -> avtomatik ko'rsatadi
assert.equal(eventTitle("rs"), "Rust ustaxonasi");
eventsKeyboard() va eventTitle EVENTS ga tayanadi, shuning uchun yangi tadbir hech qanday boshqa o'zgarishsiz paydo bo'ladi β bu "data-driven" dizaynning afzalligi.
3-mashq yechimi¶
userHandlers.command("start", (ctx) =>
ctx.reply(`Salom, ${ctx.from.first_name}! /royxat bilan tadbirga yoziling.`)
);
// Offline: mkText("/start") da from.first_name = "Ali" -> "Salom, Ali!"
const c = calls.find((x) => x.method === "sendMessage");
assert.ok(c.payload.text.includes("Salom, Ali!"));
ctx.from.first_name β Telegram'dan kelgan ism (03-bob). Buyruq update'imizda from.first_name = "Ali".
4-mashq yechimi¶
await ctx.reply("To'liq ismingizni yozing:");
const nameCtx = await conversation.waitUntil(
(ctx) => (ctx.message?.text?.trim().length ?? 0) >= 2,
{ otherwise: (ctx) => ctx.reply("Ism kamida 2 harf bo'lsin.") }
);
const fullName = nameCtx.message.text.trim();
// Offline: "A" -> otherwise; "Oqil" -> qabul
waitUntil istalgan shartni tekshiradi; shart yolg'on bo'lsa otherwise chaqirilib, maydon yana kutadi (xuddi form.text'ning otherwise'i kabi, lekin shart bizniki).
5-mashq yechimi¶
// registrations.js
remove: db.prepare("DELETE FROM registrations WHERE id = ? AND user_id = ?"),
// repository obyektiga:
remove(id, userId) { return stmts.remove.run(id, userId).changes; },
// user.js
userHandlers.command("ochirish", (ctx) => {
const id = Number(ctx.match);
if (!Number.isInteger(id)) return ctx.reply("Foydalanish: /ochirish <id>");
const n = ctx.repo.remove(id, ctx.from.id); // faqat O'Z arizasi
return ctx.reply(n ? `Ariza #${id} o'chirildi.` : "Bunday ariza topilmadi.");
});
// Offline: ariza yarat -> /ochirish 1 -> listByUser bo'sh
WHERE ... AND user_id = ? boshqa odamning arizasini o'chirishdan himoya qiladi β changes 0 qaytadi, "topilmadi" deymiz. Bu β xavfsizlikning oddiy, lekin muhim qoidasi.
6-mashq yechimi¶
// register.js β tasdiqdan keyin, saqlashdan oldin
const result = await conversation.external(() => {
const band = registrations.perEvent().find((r) => r.event === eventId)?.n ?? 0;
if (band >= 2) return { full: true };
const id = registrations.add({ user_id: ctx.from.id, full_name: fullName, phone, event: eventId });
return { full: false, id };
});
if (result.full) { await ctx.reply("Afsus, bu tadbir to'ldi."); return; }
await ctx.reply(`Yozildingiz (ariza #${result.id}).`);
// Offline: bitta tadbirga 2 ariza yarat, 3-chisi "to'ldi" olsin
Limit tekshiruvi va add bitta external ichida β shunda ikkalasi ham replay'da takrorlanmaydi va "tekshir-keyin-yoz" atomar bo'ladi. external faqat oddiy obyekt ({ full, id }) qaytaradi β seriyalanadi.
7-mashq yechimi¶
// registrations.js
recent: db.prepare("SELECT * FROM registrations ORDER BY id DESC LIMIT 5"),
recent() { return stmts.recent.all(); },
// admin.js
adminHandlers.command("oxirgi", (ctx) => {
const rows = ctx.repo.recent();
if (!rows.length) return ctx.reply("Arizalar yo'q.");
return ctx.reply(rows.map((r) => `#${r.id} ${r.full_name} β ${eventTitle(r.event)}`).join("\n"));
});
// Offline: admin (999) ko'radi; oddiy (777) javob olmaydi (darvoza)
Admin darvozasi (adminHandlers.use(...)) tufayli oddiy foydalanuvchiga /oxirgi umuman javob bermaydi.
8-mashq yechimi¶
// user.js β har arizaga inline tugma
userHandlers.command("mening", (ctx) => {
const rows = ctx.repo.listByUser(ctx.from.id);
if (!rows.length) return ctx.reply("Ariza yo'q.");
for (const r of rows) {
ctx.reply(`#${r.id} β ${eventTitle(r.event)}`, {
reply_markup: new InlineKeyboard().text("Bekor qilish", `del:${r.id}`),
});
}
});
userHandlers.callbackQuery(/^del:(\d+)$/, async (ctx) => {
const id = Number(ctx.match[1]);
ctx.repo.remove(id, ctx.from.id);
await ctx.answerCallbackQuery("O'chirildi");
await ctx.editMessageText(`#${id} β bekor qilindi`);
});
// Offline: ariza yarat -> mkCb("del:1") -> remove chaqirildi
bot.callbackQuery(/regex/) (07-bob) ctx.match orqali id'ni beradi; answerCallbackQuery "soatcha"ni to'xtatadi, editMessageText xabarni yangilaydi.
9-mashq yechimi¶
let addCount = 0;
const orig = repo.add.bind(repo);
repo.add = (reg) => { addCount++; return orig(reg); }; // external sanog'i
const calls = [];
const bot = buildBot(repo, calls);
await bot.handleUpdate(mkText("/royxat", 777));
await bot.handleUpdate(mkText("Oqil", 777));
await bot.handleUpdate(mkContact("+998901112233", 777));
await bot.handleUpdate(mkCb("ev:py", 777));
await bot.handleUpdate(mkCb("ok", 777));
const rows = repo.listByUser(777);
assert.equal(rows.length, 1);
assert.equal(rows[0].event, "py");
assert.equal(rows[0].phone, "+998901112233");
assert.equal(addCount, 1); // external -> replay'da takrorlanmadi
Bu β bobning markaziy testi (_verify_18.mjs dagi t1+t8). 5 ta update kelsa-da, suhbat funksiyasi ko'p marta replay qilinadi, lekin repo.add external ichida bo'lgani uchun aynan bir marta ishlaydi.
10-mashq yechimi¶
// inject'dan keyin session (10-bob)
bot.use(session({ initial: () => ({ lang: "uz" }) }));
const T = {
uz: { hi: "Salom!", reg: "Ro'yxatdan o'tish" },
en: { hi: "Hello!", reg: "Registration" },
};
userHandlers.command("til", async (ctx) => {
await ctx.reply("Til / Language:", {
reply_markup: new InlineKeyboard().text("Uzbekcha", "lang:uz").text("English", "lang:en"),
});
});
userHandlers.callbackQuery(/^lang:(uz|en)$/, async (ctx) => {
ctx.session.lang = ctx.match[1];
await ctx.answerCallbackQuery();
await ctx.reply(T[ctx.session.lang].hi);
});
userHandlers.command("start", (ctx) => ctx.reply(T[ctx.session.lang].hi));
// Offline: lang:en callback -> /start -> "Hello!"
Til sessiyada saqlanadi (kalit = chat). session() ni conversations()'dan oldin ulang. Suhbat ichida tilni o'qish kerak bo'lsa β external/session flavor orqali (10-bob).
11-mashq yechimi¶
const himoyalangan = bot.errorBoundary((err) => {
console.error("Admin moduli xato:", err.error.message); // next() YO'Q -> yutiladi
});
himoyalangan.use(adminHandlers); // admin handlerlari chegara ichida
// Offline:
adminHandlers.command("admin", () => { throw new Error("portladi"); });
await bot.handleUpdate(mkText("/admin", 999)); // chegarada ushlanadi, yuqoriga chiqmaydi
await bot.handleUpdate(mkText("/start", 777)); // hamon ishlaydi
assert.ok(sentTexts(calls).some((t) => t.includes("Salom")));
errorBoundary admin moduliga "qalqon" qo'yadi: u portlasa ham, qolgan bot (/start) ishlashda davom etadi (09-bob). handleUpdate bilan testda chegara ichidagi xato yuqoriga otilmaydi (chunki next() chaqirilmadi).
12-mashq yechimi¶
// register.js β tasdiqdan keyin (ok shoxida), saqlashdan keyin
await confirm.answerCallbackQuery();
await confirm.editMessageReplyMarkup({ reply_markup: undefined }); // tugmalarni olib tashlash
if (confirm.match === "cancel") { /* ... */ }
// ...
const id = await conversation.external(() => registrations.add({...}));
// Offline: ok callback'dan keyin transformer'da editMessageReplyMarkup borligini tekshir
assert.ok(calls.some((c) => c.method === "editMessageReplyMarkup"));
editMessageReplyMarkup (yoki editMessageText) tugmalarni olib tashlaydi β foydalanuvchi tasdiqdan keyin qayta bosa olmaydi. reply_markup: undefined markupni butunlay yo'qotadi.
13-mashq yechimi¶
// db.js migratsiyasi (mavjud jadvalga ustun)
db.exec("ALTER TABLE registrations ADD COLUMN attended INTEGER NOT NULL DEFAULT 0");
// (faqat ustun yo'q bo'lsa; PRAGMA table_info bilan tekshirib qo'shing)
// registrations.js
markAttended: db.prepare("UPDATE registrations SET attended = 1 WHERE id = ?"),
attendedCount: db.prepare("SELECT COUNT(*) AS n FROM registrations WHERE attended = 1"),
markAttended(id) { return stmts.markAttended.run(id).changes; },
attendedCount() { return stmts.attendedCount.get().n; },
// admin.js
adminHandlers.command("keldi", (ctx) => {
const id = Number(ctx.match);
const n = ctx.repo.markAttended(id);
return ctx.reply(n ? `#${id} keldi deb belgilandi.` : "Topilmadi.");
});
// /admin ga qo'shing: `kelganlar: ${ctx.repo.attendedCount()}`
// Offline: ariza yarat -> /keldi 1 (999) -> /admin'da "kelganlar: 1"
ALTER TABLE bilan mavjud bazaga ustun qo'shamiz (migratsiya β SQL kitobida). Production'da migratsiyalarni alohida fayllarda boshqaring; bu yerda soddalashtirdik.
Yo'l yakuni va keyingi qadam. Tabriklaymiz β siz kitobning I-V qismlarini (asoslar, muloqot, arxitektura, ilg'or, sifat/deploy) bitta ishlaydigan botda birlashtirdingiz! Bu skelet bilan siz endi haqiqiy botlar yozishingiz mumkin: kurs/navbat/buyurtma β barchasi shu naqshda. Lekin yo'l hali tugamadi: VI qism botingizni guruh va kanallarga, so'ng Mini App dunyosiga olib chiqadi. 19-bobda botni guruhlarda ishlatishni β privacy mode,
getChatMemberbilan a'zo/admin tekshirish,my_chat_memberβ o'rganamiz; keyin moderatsiya, majburiy obuna, Telegram Web App (Mini App) va Hamster uslubidagi clicker o'yin kapstoniga (26-bob) o'tamiz. Python ekvivalenti uchun aiogram kapstoni β ../tgbot-python/README.md.
Offline tekshirish hisoboti¶
Bobdagi kapston botning butun mantig'i _verify_18.mjs da uchma-uch ishga tushirildi (grammY 1.43.0, @grammyjs/conversations 2.1.1, better-sqlite3 12.10.0, Node v24, ESM). Soxta Update'lar bot.handleUpdate(...) ga uzatildi, chiqayotgan API chaqiruvlari transformer bilan ushlandi, conversations ichki konteksti uchun transformer plugins orqali ulandi, DB temp faylda yaratilib oxirida o'chirildi.
PASS: To'liq oqim (kontakt): conversation -> DB INSERT -> tasdiq (1 ariza)
PASS: Matn telefon + /mening: ikki ariza ro'yxatda ko'rsatildi
PASS: Inline 'Bekor': ariza DB'ga yozilmadi
PASS: Tashqi /bekor: faol suhbatni to'xtatadi (ctx.conversation.exit)
PASS: Admin statistika: faqat admin ko'radi, hisob to'g'ri (3 = 2js+1py)
PASS: Admin broadcast: distinct foydalanuvchilarga yuborildi (2 ta)
PASS: Broadcast bo'sh matn: foydalanish ko'rsatmasi
PASS: external: replay bo'lsa-da repo.add aynan 1 marta chaqirildi
PASS: /mening bo'sh: 'hali ariza yo'q' xabari
HAMMASI O'TDI: 9/9
Test DB tozalandi: true
Tasdiqlangan xulosalar: (1) to'liq ro'yxatdan o'tish oqimi (ism -> kontakt/matn telefon -> tadbir tanlash -> tasdiq) bazaga aynan bir ariza yozadi; (2) external replay'da takrorlanmaydi β repo.add aynan bir marta chaqiriladi; (3) inline "Bekor" va tashqi /bekor suhbatni to'xtatadi va DB'ga yozmaydi; (4) /mening foydalanuvchining arizalarini to'g'ri ko'rsatadi; (5) admin darvozasi ishlaydi β oddiy foydalanuvchiga /admin//broadcast javob bermaydi; (6) broadcast takrorlanmas (distinct) foydalanuvchilarga yuboriladi. Jonli ishlash (token bilan polling/webhook, haqiqiy Telegram'ga xabar) β internet talab qiladi; run()/pm2/Docker/webhook buyruqlari illustrativ.
β¬ οΈ Oldingi: 17 β Production va deploy Β· π README Β· Keyingi: 19 β Guruhlarda ishlash β‘οΈ