18 β Yakuniy kapston: to'liq bot¶
β¬ οΈ Oldingi: 17 β Production va deploy Β· π README Β· Keyingi: 19 β Guruhlarda ishlash β‘οΈ
Bu bobda: Hozirgacha har bobda Nutgram'ning bitta qismini o'rgandik β handlerlar, klaviaturalar, callback, conversation, middleware, ma'lumotlar bazasi, deploy. Endi ularning hammasini bitta haqiqiy loyihada birlashtiramiz: vazifa-menejer bot (mini to-do). Foydalanuvchi
/add(yoki menyu tugmasi) bilan vazifa qo'shadi (Conversationforma + validatsiya), vazifani DB'ga (PDO/SQLite) yozadi,/listbilan ro'yxatni inline tugmalar (Bajarildi/O'chirish) bilan ko'radi, callback orqali vazifani belgilaydi yoki o'chiradi. Har bir update middleware zanjiridan o'tadi: foydalanuvchini DB'ga upsert qilamiz, bloklanganlarni to'samiz, anti-flood (throttle) qo'yamiz; admin buyruqlar (/stats,/ban) esa alohida gateli guruhda. Eng muhimi β loyiha tuzilishi (src/, repozitoriylar, bittaBot::buildbootstrap), FakeNutgram + PDO sqlite bilan offline testlar, va deploy yo'riqnomasi (polling vs webhook β 13, 17). Bu bob β kitobning amaliy yuragi: undan keyin sizda butun, testlangan, deploy qilinadigan bot bo'ladi.Halol eslatma: Bu botning BARCHA mantig'i β conversation oqimi va validatsiyasi, DB CRUD va egalik tekshiruvi (begona vazifani o'zgartirib bo'lmasligi), inline callback (done/del), uchala middleware (upsert, ban-gate, throttle), admin-gate va klaviatura quruvchilari β
Nutgram::fake()(FakeNutgram) + PDO sqlite (:memory:) bilan tokensiz, tarmoqsiz haqiqatan ishga tushirib tekshirilgan: jami 18 ta PHPUnit testi, 63 ta assert, hammasi o'tdi (Nutgram 4.46, PHP 8.4). Qaysi testlar β bob oxirida ro'yxat bilan. Jonli qism (realsendMessage, telefonda tugmalar, webhook orqali HTTPS, jonli deploy) β illustrativ deb belgilangan; hech qayerda soxta "bot ishladi / xabar yetdi" yozilmagan.
Nimani quramiz? β talablar va arxitektura¶
Vazifa-menejer bot quyidagilarni qiladi:
| Imkoniyat | Qanday | Qaysi bob |
|---|---|---|
| Vazifa qo'shish (forma) | AddTask conversation + validatsiya |
08 |
| Vazifalarni saqlash/ro'yxat | PDO repozitoriylar (SQLite/MySQL) | 10 |
| Doimiy menyu | Reply klaviatura | 06 |
| Bajarildi / o'chirish | Inline tugma + callback | 06, 07 |
| Foydalanuvchini ro'yxatga olish, bloklash, anti-flood | Middleware | 09 |
Admin buyruqlar (/stats, /ban) |
Guruh + admin-gate | 09 |
| Test | FakeNutgram + PDO | 16 |
| Deploy | Polling / webhook | 13, 17 |
Har bir update bir xil yo'ldan o'tadi: avval middleware zanjiri, so'ng mos handler, ma'lumot esa repozitoriy orqali DB'ga boradi.
PHP eslatma: Bu bob siz PHP'ni bilasiz deb faraz qiladi (sinf, namespace, PDO, closure, type hints β ../php/README.md). Biz Telegram/Nutgram'ga xos narsalarni tushuntiramiz; PDO/PHP asoslarini esa qayta o'rgatmaymiz (chuqurroq SQL uchun ../sql/README.md, Laravel'da xuddi shu g'oyalar uchun ../laravel/README.md).
Loyiha tuzilishi¶
Bitta bot.php faylga hamma narsani tiqishtirish o'rganish uchun mayli, lekin haqiqiy loyihada kodni sinflar va papkalarga ajratamiz. Bizning tuzilish (11-bob tamoyillariga amal qiladi):
vazifa-bot/
βββ src/
β βββ Bot.php # bootstrap: hamma narsani ulaydigan build()
β βββ Database.php # PDO ulanish + migratsiya
β βββ UserRepository.php # users jadvali bilan ishlash
β βββ TaskRepository.php # tasks jadvali bilan ishlash
β βββ Keyboards.php # reply/inline klaviatura quruvchilari
β βββ Conversations/
β β βββ AddTask.php # vazifa qo'shish formasi
β βββ Middleware/
β βββ RegisterUser.php # har update'da foydalanuvchini upsert
β βββ BlockBanned.php # bloklanganlarni to'sish (gate)
β βββ Throttle.php # anti-flood
β βββ EnsureAdmin.php # admin-gate
βββ tests/
β βββ BotTest.php # FakeNutgram + PDO testlar
βββ bot.php # kirish nuqtasi (long-polling)
βββ webhook.php # kirish nuqtasi (webhook / HTTPS)
βββ .env # TELEGRAM_TOKEN (kodga YOZILMAYDI)
βββ composer.json
composer.json da PSR-4 autoload va kerakli paketlar:
{
"require": {
"php": "^8.4",
"nutgram/nutgram": "^4.46"
},
"require-dev": {
"phpunit/phpunit": "^12.0"
},
"autoload": {
"psr-4": { "App\\": "src/" }
},
"autoload-dev": {
"psr-4": { "App\\Tests\\": "tests/" }
}
}
Ma'lumotlar bazasi: ulanish va jadvallar¶
Avval DB qatlami. Database::connect() PDO ulanishini ochadi va jadvallarni yaratadi (migratsiya). O'rganishda SQLite (fayl yoki :memory:), production'da MySQL β ikkalasi ham PDO orqali bir xil ishlaydi:
<?php
namespace App;
use PDO;
class Database
{
public static function connect(string $dsn = 'sqlite::memory:'): PDO
{
$pdo = new PDO($dsn, options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
self::migrate($pdo);
return $pdo;
}
public static function migrate(PDO $pdo): void
{
$pdo->exec('
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY, -- Telegram user id
first_name TEXT NOT NULL DEFAULT \'\',
username TEXT,
is_banned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
');
$pdo->exec('
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
');
}
}
Nega
users.idβ Telegram ID? Foydalanuvchining Telegram ID'si o'zi noyob va o'zgarmas β uniPRIMARY KEYqilib olsak,INSERT ... ON CONFLICT(upsert) tabiiy bo'ladi. Vazifaninguser_idustuni shu ID'ga ishora qiladi.
Repozitoriylar (PDO bilan ishlash sinflari)¶
Handlerlar SQL'ni to'g'ridan-to'g'ri yozmasin β buni repozitoriy sinflariga ajratamiz. Avval foydalanuvchilar:
<?php
namespace App;
use PDO;
class UserRepository
{
public function __construct(private PDO $pdo) {}
/** Ko'rdik -> bo'lmasa yaratamiz, bo'lsa ism/username yangilaymiz (idempotent). */
public function upsert(int $id, string $firstName, ?string $username): void
{
$stmt = $this->pdo->prepare('
INSERT INTO users (id, first_name, username, created_at)
VALUES (:id, :first_name, :username, :created_at)
ON CONFLICT(id) DO UPDATE SET
first_name = excluded.first_name,
username = excluded.username
');
$stmt->execute([
'id' => $id, 'first_name' => $firstName,
'username' => $username, 'created_at' => gmdate('c'),
]);
}
public function isBanned(int $id): bool
{
$stmt = $this->pdo->prepare('SELECT is_banned FROM users WHERE id = :id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetchColumn();
}
public function setBanned(int $id, bool $banned): void
{
$stmt = $this->pdo->prepare('UPDATE users SET is_banned = :b WHERE id = :id');
$stmt->execute(['b' => $banned ? 1 : 0, 'id' => $id]);
}
public function count(): int
{
return (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
}
}
Endi vazifalar repozitoriysi. Diqqat: har bir o'zgartirish/o'chirish egalikni tekshiradi (AND user_id = :uid) β foydalanuvchi faqat o'z vazifasiga ta'sir qila olsin:
<?php
namespace App;
use PDO;
class TaskRepository
{
public function __construct(private PDO $pdo) {}
public function add(int $userId, string $title): int
{
$stmt = $this->pdo->prepare('
INSERT INTO tasks (user_id, title, created_at)
VALUES (:user_id, :title, :created_at)
');
$stmt->execute(['user_id' => $userId, 'title' => $title, 'created_at' => gmdate('c')]);
return (int) $this->pdo->lastInsertId();
}
/** @return array<int, array{id:int,title:string,done:int}> */
public function listForUser(int $userId): array
{
$stmt = $this->pdo->prepare('
SELECT id, title, done FROM tasks
WHERE user_id = :user_id ORDER BY done ASC, id ASC
');
$stmt->execute(['user_id' => $userId]);
return $stmt->fetchAll();
}
public function find(int $id, int $userId): ?array
{
$stmt = $this->pdo->prepare('SELECT id, title, done FROM tasks WHERE id = :id AND user_id = :uid');
$stmt->execute(['id' => $id, 'uid' => $userId]);
return $stmt->fetch() ?: null;
}
/** Egasini tekshirib "bajarildi" qiladi; o'zgargan qator sonini qaytaradi (0 = sizniki emas). */
public function markDone(int $id, int $userId): int
{
$stmt = $this->pdo->prepare('UPDATE tasks SET done = 1 WHERE id = :id AND user_id = :uid AND done = 0');
$stmt->execute(['id' => $id, 'uid' => $userId]);
return $stmt->rowCount();
}
/** Egasini tekshirib o'chiradi; o'chirilgan qator sonini qaytaradi. */
public function delete(int $id, int $userId): int
{
$stmt = $this->pdo->prepare('DELETE FROM tasks WHERE id = :id AND user_id = :uid');
$stmt->execute(['id' => $id, 'uid' => $userId]);
return $stmt->rowCount();
}
public function countAll(): int
{
return (int) $this->pdo->query('SELECT COUNT(*) FROM tasks')->fetchColumn();
}
}
XAVFSIZLIK β egalik tekshiruvi (juda muhim). Inline tugmaning
callback_datasida vazifa ID'si turadi (done:42). Lekin boshqa foydalanuvchi sun'iy callback yuborib, sizning vazifangizni o'chirishga urinishi mumkin. Shuning uchun har birUPDATE/DELETEdaAND user_id = :uidshart β begona ID berilsa,rowCount()0 qaytadi va hech narsa o'zgarmaydi. Buni pastda FakeNutgram bilan aniq tekshiramiz (begona vazifa rad etiladi). Hech qachon callback'dan kelgan ID'ga "ishonib" egalikni tekshirmay qolmang.
Conversation: vazifa qo'shish formasi¶
Vazifa qo'shish β bu bir savolli forma: bot nom so'raydi, foydalanuvchi yozadi, biz validatsiya qilib DB'ga saqlaymiz. 08-bobdagi Conversationdan meros olamiz:
<?php
namespace App\Conversations;
use App\Keyboards;
use App\TaskRepository;
use SergiX44\Nutgram\Conversations\Conversation;
use SergiX44\Nutgram\Nutgram;
class AddTask extends Conversation
{
// Vazifa nomi β qadamlar oralig'ida Nutgram avtomatik serializatsiya qiladi.
public ?string $title = null;
public function start(Nutgram $bot)
{
$bot->sendMessage("Yangi vazifa nomini yozing (yoki /cancel):");
$this->next('saqlash');
}
public function saqlash(Nutgram $bot)
{
$title = trim((string) $bot->message()->text);
// Validatsiya: noto'g'rida next() chaqirMAymiz -> shu qadamda QOLAMIZ.
if ($title === '') {
$bot->sendMessage("Vazifa nomi bo'sh bo'lmasligi kerak. Qayta yozing:");
return;
}
if (mb_strlen($title) > 100) {
$bot->sendMessage("Juda uzun (max 100 belgi). Qisqaroq yozing:");
return;
}
$this->title = $title;
// TaskRepository konteynerdan keladi (bootstrap'da middleware joylaydi).
/** @var TaskRepository $tasks */
$tasks = $bot->getContainer()->get(TaskRepository::class);
$tasks->add($bot->userId(), $this->title);
$bot->sendMessage("\u{2705} Qo'shildi: {$this->title}", reply_markup: Keyboards::mainMenu());
$this->end();
}
}
Ikki nozik joy:
- Validatsiya =
next()sizreturn. Bo'sh yoki juda uzun nom kelsa,next()chaqirmaymiz β conversationsaqlashqadamida qoladi, foydalanuvchi qayta urinadi (08-bob naqsh). - Repozitoriyni qayerdan olamiz? Conversation qadami ham handler kabi
$botorqali ishlaydi. Repozitoriyni global o'zgaruvchidan emas, konteynerdan (getContainer()->get(...)) olamiz β uni har update'da middleware joylaydi (pastda ko'ramiz). Bu test paytida ham toza ishlaydi: testda boshqa DB beramiz, kod o'zgarmaydi.
Klaviaturalar: doimiy menyu va vazifa tugmalari¶
Ikki klaviatura kerak: pastda doimiy turadigan reply menyu va har vazifa ostidagi inline tugmalar (06-bob). Quruvchilarni alohida sinfga ajratamiz:
<?php
namespace App;
use SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton;
use SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup;
use SergiX44\Nutgram\Telegram\Types\Keyboard\KeyboardButton;
use SergiX44\Nutgram\Telegram\Types\Keyboard\ReplyKeyboardMarkup;
class Keyboards
{
/** Doimiy past menyu. */
public static function mainMenu(): ReplyKeyboardMarkup
{
return ReplyKeyboardMarkup::make(resize_keyboard: true)
->addRow(
KeyboardButton::make("\u{2795} Vazifa qo'shish"),
KeyboardButton::make("\u{1F4CB} Vazifalarim"),
);
}
/** Bitta vazifa qatori: [Bajarildi] [O'chirish]. ID callback_data ichida: "done:42". */
public static function taskRow(array $task): InlineKeyboardMarkup
{
$id = $task['id'];
$buttons = [];
if (!$task['done']) {
$buttons[] = InlineKeyboardButton::make("\u{2705} Bajarildi", callback_data: "done:$id");
}
$buttons[] = InlineKeyboardButton::make("\u{1F5D1} O'chirish", callback_data: "del:$id");
return InlineKeyboardMarkup::make()->addRow(...$buttons);
}
}
Bajarilgan vazifada "Bajarildi" tugmasi kerakmas.
taskRow()shartli quradi: vazifa allaqachon bajarilgan bo'lsa, faqat[O'chirish]chiqadi. Bu β klaviaturani ma'lumotga qarab qurishning oddiy misoli. Pastda buni testda ham tekshiramiz (bajarilmagan -> 2 tugma, bajarilgan -> 1 tugma).
Middleware: ro'yxatga olish, bloklash, anti-flood, admin¶
Endi 09-bobning middleware'larini ishga solamiz. Hammasi class-based (__invoke) β qayta ishlatish va test qulay.
1) RegisterUser β har update'da foydalanuvchini DB'ga upsert qiladi:
<?php
namespace App\Middleware;
use App\UserRepository;
use SergiX44\Nutgram\Nutgram;
class RegisterUser
{
public function __construct(private UserRepository $users) {}
public function __invoke(Nutgram $bot, $next): void
{
$user = $bot->user();
if ($user !== null) {
$this->users->upsert($user->id, $user->first_name ?? '', $user->username);
}
$next($bot);
}
}
2) BlockBanned β bloklangan foydalanuvchini to'sadi (short-circuit, $next chaqirilmaydi):
<?php
namespace App\Middleware;
use App\UserRepository;
use SergiX44\Nutgram\Nutgram;
class BlockBanned
{
public function __construct(private UserRepository $users) {}
public function __invoke(Nutgram $bot, $next): void
{
$id = $bot->userId();
if ($id !== null && $this->users->isBanned($id)) {
$bot->sendMessage("\u{26D4} Siz bloklangansiz.");
return; // handlergacha yetib bormaydi
}
$next($bot);
}
}
Tartib muhim.
RegisterUserniBlockBanned'dan oldin ro'yxatdan o'tkazamiz β shunda ban tekshiruvi paytida foydalanuvchi DB'da bo'lishi kafolatlangan (upsert allaqachon ishlagan). 09-bobda ko'rganimizdek,$bot->middleware()chaqirilish tartibi = ijro tartibi (birinchi qo'shilgan β eng tashqi qatlam).
3) Throttle β oddiy anti-flood (getUserData/setUserData orqali, 09-bob):
<?php
namespace App\Middleware;
use SergiX44\Nutgram\Nutgram;
class Throttle
{
public function __construct(private int $seconds = 1) {}
public function __invoke(Nutgram $bot, $next): void
{
$now = microtime(true);
$last = (float) $bot->getUserData('last_action', default: 0);
if ($now - $last < $this->seconds) {
return; // short-circuit β spamda jim qaytamiz (qo'shimcha flood bo'lmasin)
}
$bot->setUserData('last_action', $now);
$next($bot);
}
}
4) EnsureAdmin β admin-gate (faqat sozlamadagi ID'lar o'tadi):
<?php
namespace App\Middleware;
use SergiX44\Nutgram\Nutgram;
class EnsureAdmin
{
/** @param int[] $admins */
public function __construct(private array $admins) {}
public function __invoke(Nutgram $bot, $next): void
{
if (!in_array($bot->userId(), $this->admins, true)) {
$bot->sendMessage("Bu bo'lim faqat adminlar uchun.");
return; // short-circuit
}
$next($bot);
}
}
Bootstrap: hammasini bitta Bot::build ga ulash¶
Mana β loyihaning yuragi. Bot::build() Nutgram obyekti va PDO oladi, hamma middleware/handler/conversation'ni ulaydi va tayyor botni qaytaradi. Bitta bootstrap β uni ham polling, ham webhook, ham test ishlatadi:
<?php
namespace App;
use App\Conversations\AddTask;
use App\Middleware\BlockBanned;
use App\Middleware\EnsureAdmin;
use App\Middleware\RegisterUser;
use PDO;
use SergiX44\Nutgram\Nutgram;
class Bot
{
/** @param int[] $admins */
public static function build(Nutgram $bot, PDO $pdo, array $admins = []): Nutgram
{
$users = new UserRepository($pdo);
$tasks = new TaskRepository($pdo);
// --- Global middleware (tartib: avval upsert, keyin ban-gate) ---
$bot->middleware(new RegisterUser($users));
$bot->middleware(new BlockBanned($users));
// Repozitoriylarni har update doirasida konteynerga joylaymiz β
// conversation/handler ulardan getContainer()->get(...) bilan oladi.
$bot->middleware(function (Nutgram $bot, $next) use ($users, $tasks) {
$bot->getContainer()->set(UserRepository::class, $users);
$bot->getContainer()->set(TaskRepository::class, $tasks);
$next($bot);
});
// --- Asosiy buyruqlar ---
$bot->onCommand('start', function (Nutgram $bot) {
$bot->sendMessage(
"Salom! Bu β vazifa-menejer bot.\n" .
"Vazifa qo'shish yoki ro'yxatni ko'rish uchun pastdagi menyudan foydalaning.",
reply_markup: Keyboards::mainMenu(),
);
});
$bot->onCommand('help', fn (Nutgram $bot) => $bot->sendMessage(
"Buyruqlar:\n/start β boshlash\n/add β vazifa qo'shish\n/list β vazifalarim"
));
// Vazifa qo'shish: buyruq ham, reply-tugma matni ham conversation'ni boshlaydi.
$startAdd = fn (Nutgram $bot) => AddTask::begin($bot);
$bot->onCommand('add', $startAdd);
$bot->onText("\u{2795} Vazifa qo'shish", $startAdd);
// Ro'yxat:
$showList = fn (Nutgram $bot) => self::sendList($bot, $tasks);
$bot->onCommand('list', $showList);
$bot->onText("\u{1F4CB} Vazifalarim", $showList);
// /cancel β aktiv conversation'ni Nutgram o'zi to'xtatadi (pattern'li handler).
$bot->onCommand('cancel', fn (Nutgram $bot) => $bot->sendMessage('Bekor qilindi.'));
// --- Inline callback'lar: bajarildi / o'chirish ---
$bot->onCallbackQueryData('done:{id}', function (Nutgram $bot, int $id) use ($tasks) {
if ($tasks->markDone($id, $bot->userId()) > 0) {
$bot->answerCallbackQuery(text: 'Bajarildi deb belgilandi.');
$bot->editMessageText("\u{2705} Bajarildi (#$id)");
} else {
$bot->answerCallbackQuery(text: 'Topilmadi yoki sizniki emas.', show_alert: true);
}
});
$bot->onCallbackQueryData('del:{id}', function (Nutgram $bot, int $id) use ($tasks) {
if ($tasks->delete($id, $bot->userId()) > 0) {
$bot->answerCallbackQuery(text: "O'chirildi.");
$bot->editMessageText("\u{1F5D1} O'chirildi (#$id)");
} else {
$bot->answerCallbackQuery(text: 'Topilmadi yoki sizniki emas.', show_alert: true);
}
});
// --- Admin guruhi (gate bilan o'ralgan) ---
$bot->group(function (Nutgram $bot) use ($users, $tasks) {
$bot->onCommand('stats', fn (Nutgram $bot) => $bot->sendMessage(
"\u{1F4CA} Statistika:\n" .
"Foydalanuvchilar: {$users->count()}\n" .
"Vazifalar: {$tasks->countAll()}"
));
// Buyruq argumenti: onCommand('ban {id}') -> handlerga $id parametr bo'lib keladi.
$bot->onCommand('ban {id}', function (Nutgram $bot, string $id) use ($users) {
if (!ctype_digit($id)) {
$bot->sendMessage('Foydalanish: /ban <user_id>');
return;
}
$users->setBanned((int) $id, true);
$bot->sendMessage("Foydalanuvchi $id bloklandi.");
});
})->middleware(new EnsureAdmin($admins));
// Tanish bo'lmagan matn β yordam.
$bot->fallback(fn (Nutgram $bot) => $bot->sendMessage('Tushunmadim. /help'));
return $bot;
}
private static function sendList(Nutgram $bot, TaskRepository $tasks): void
{
$rows = $tasks->listForUser($bot->userId());
if ($rows === []) {
$bot->sendMessage("Vazifalaringiz yo'q. Qo'shish: /add");
return;
}
foreach ($rows as $task) {
$belgi = $task['done'] ? "\u{2705}" : "\u{2B1C}";
$bot->sendMessage("$belgi {$task['title']}", reply_markup: Keyboards::taskRow($task));
}
}
}
Diqqat qiladigan nuqtalar:
onCommandham,onTextham bitta closure'ga ulanadi ($startAdd,$showList) β/addbuyrug'i va "β Vazifa qo'shish" tugmasi bir xil ishni qiladi. Reply tugma bosilganda Telegram oddiy matn xabari yuboradi (tugmadagi yozuv) β shuning uchunonTextbilan ushlaymiz (06-bob).onCallbackQueryData('done:{id}')β{id}named parametr. Handlerint $idni avtomatik oladi (07-bob). ShuningdekonCommand('ban {id}')ham xuddi shunday β buyruq argumenti$idga uzatiladi.- Admin guruhi
->middleware(new EnsureAdmin(...))bilan o'ralgan β/statsva/banfaqat adminlar uchun, gate butun guruhni qoplaydi (09-bob).
Kirish nuqtalari: polling va webhook¶
Bootstrap bitta, lekin botni ishga tushirish ikki xil bo'lishi mumkin (13-bob). O'rganish/kichik bot uchun long-polling:
<?php
// bot.php β long-polling kirish nuqtasi
require __DIR__ . '/vendor/autoload.php';
use App\Bot;
use App\Database;
use SergiX44\Nutgram\Nutgram;
$token = getenv('TELEGRAM_TOKEN') ?: throw new RuntimeException('TELEGRAM_TOKEN yo\'q');
$admins = array_map('intval', explode(',', getenv('ADMIN_IDS') ?: ''));
$pdo = Database::connect(getenv('DB_DSN') ?: 'sqlite:' . __DIR__ . '/data.sqlite');
$bot = Bot::build(new Nutgram($token), $pdo, $admins);
$bot->run(); // long-polling: getUpdates tsiklini ishga tushiradi
Production uchun webhook β bir xil bootstrap, faqat HTTPS endpoint (13, 17):
<?php
// webhook.php β Telegram POST so'rovini qabul qiladigan HTTPS endpoint
require __DIR__ . '/vendor/autoload.php';
use App\Bot;
use App\Database;
use SergiX44\Nutgram\Configuration;
use SergiX44\Nutgram\Nutgram;
use SergiX44\Nutgram\RunningMode\Webhook;
$token = getenv('TELEGRAM_TOKEN');
$admins = array_map('intval', explode(',', getenv('ADMIN_IDS') ?: ''));
$pdo = Database::connect(getenv('DB_DSN'));
// Production'da conversation/cache uchun Redis (illustrativ β server kerak):
$config = new Configuration(/* cache: $redisPsr16 */);
$bot = Bot::build(new Nutgram($token, $config), $pdo, $admins);
$bot->setRunningMode(Webhook::class);
$bot->run(); // joriy HTTP so'rovdagi bitta update'ni ishlaydi
Halol eslatma (deploy). Webhook uchun public HTTPS domen kerak (
setWebhookni bir marta chaqirasiz), polling uchun esa doimiy ishlab turadigan protsess (systemd/supervisor). Bularning ikkalasi ham jonli muhitni talab qiladi β bu yerda kod illustrativ, biz uni real Telegram'ga ulamadik. Conversation holatini saqlash uchun production'daArrayCacheemas, Redis/fayl cache ishlating (aks holda bot restart bo'lganda yarim qolgan formalar yo'qoladi β 08, 11, 17). Token har doim.env/getenvdan keladi, kodga yozilmaydi.
Testlash: FakeNutgram + PDO sqlite (OFFLINE)¶
Mana eng muhim qism β hamma narsani tokensiz, tarmoqsiz tekshirish. Nutgram::fake() (16-bob) + :memory: SQLite bilan butun botni ishga tushiramiz. Test bazasi bootstrap'ga argument sifatida uzatilgani uchun (Bot::build($bot, $pdo, ...)) hech narsani mock qilish kerak emas β haqiqiy DB, faqat xotirada.
Test bazasi (qisqartirilgan β to'liq versiyada 18 ta test):
<?php
namespace App\Tests;
use App\Bot;
use App\Database;
use App\Keyboards;
use App\TaskRepository;
use App\UserRepository;
use PDO;
use PHPUnit\Framework\TestCase;
use SergiX44\Nutgram\Nutgram;
use SergiX44\Nutgram\Telegram\Types\User\User;
use SergiX44\Nutgram\Telegram\Types\Chat\Chat;
final class BotTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
// Har test uchun toza in-memory DB (migratsiya avtomatik).
$this->pdo = Database::connect('sqlite::memory:');
}
private function fakeBot(int $userId = 555, array $admins = []): Nutgram
{
$bot = Nutgram::fake();
// Barcha update'lar bitta foydalanuvchi/chat'dan kelsin (DB/middleware izolyatsiyasi).
$bot->setCommonUser(User::make(id: $userId, is_bot: false, first_name: 'Test'));
$bot->setCommonChat(Chat::make(id: $userId, type: 'private'));
return Bot::build($bot, $this->pdo, $admins);
}
public function test_add_task_conversation_saves_to_db(): void
{
$bot = $this->fakeBot(555);
$bot->willStartConversation(); // ko'p qadamli suhbat uchun SHART
$bot->hearText('/add')->reply();
$bot->assertReplyText("Yangi vazifa nomini yozing (yoki /cancel):");
$bot->assertActiveConversation();
$bot->hearText('Kitob yozish')->reply();
$bot->assertReplyText("\u{2705} Qo'shildi: Kitob yozish");
$bot->assertNoConversation();
// DB'da HAQIQATAN paydo bo'ldimi:
$rows = (new TaskRepository($this->pdo))->listForUser(555);
$this->assertCount(1, $rows);
$this->assertSame('Kitob yozish', $rows[0]['title']);
}
public function test_callback_on_foreign_task_is_rejected(): void
{
$tasks = new TaskRepository($this->pdo);
$id = $tasks->add(999, 'Boshqaniki'); // egasi 999
$bot = $this->fakeBot(555); // 555 urinmoqda
$bot->hearCallbackQueryData("done:$id")->reply();
$bot->assertCalled('answerCallbackQuery'); // alert: sizniki emas
$this->assertSame(0, $tasks->find($id, 999)['done']); // o'zgarmadi!
}
public function test_banned_user_is_blocked(): void
{
(new UserRepository($this->pdo))->upsert(555, 'Test', null);
(new UserRepository($this->pdo))->setBanned(555, true);
$bot = $this->fakeBot(555);
$bot->hearText('/list')->reply();
$bot->assertReplyText("\u{26D4} Siz bloklangansiz."); // short-circuit ishladi
}
public function test_admin_gate_blocks_non_admin(): void
{
$bot = $this->fakeBot(555, admins: [111]); // 555 admin emas
$bot->hearText('/stats')->reply();
$bot->assertReplyText("Bu bo'lim faqat adminlar uchun.");
}
public function test_keyboard_builders(): void
{
$this->assertCount(2, Keyboards::taskRow(['id' => 5, 'done' => 0])->inline_keyboard[0]); // [Bajarildi][O'chirish]
$this->assertCount(1, Keyboards::taskRow(['id' => 5, 'done' => 1])->inline_keyboard[0]); // faqat [O'chirish]
}
}
Test sirlari (FakeNutgram bilan). Uch narsa muhim: (1)
setCommonUser/setCommonChatbilan barcha update'larni bitta foydalanuvchidan qilamiz β aks holda FakeNutgram har safar tasodifiy ID yaratadi va middleware/DB izolyatsiyasini tekshirib bo'lmaydi. (2) Ko'p qadamli conversation uchunwillStartConversation()shart (08-bob). (3) Test bazasini konstruktor argumenti orqali uzatamiz β shuning uchun:memory:SQLite "haqiqiy" DB sifatida ishlaydi, mock kerak emas.
Tekshirilgan natija (haqiqiy)¶
Yuqoridagi test bazasini PHPUnit bilan ishga tushirdik. Natija aynan quyidagicha bo'ldi (Nutgram 4.46, PHP 8.4, hech qanday token/tarmoq yo'q):
PHPUnit 12.5.29 by Sebastian Bergmann and contributors.
.................. 18 / 18 (100%)
OK (18 tests, 63 assertions)
Bu soxta emas β :memory: SQLite va FakeNutgram bilan har bir mantiq tom ma'noda ishga tushirildi. Masalan, test_add_task_conversation_saves_to_db da /add -> "Kitob yozish" oqimidan keyin DB'dan listForUser(555) o'qildi va bitta vazifa ('Kitob yozish') topildi β conversation rostdan DB'ga yozgani isbotlandi. test_callback_on_foreign_task_is_rejected da 555-foydalanuvchi 999-ning vazifasiga done: callback yubordi, lekin find() done = 0 qaytardi β egalik tekshiruvi haqiqatan to'sdi.
Jonli ko'rinish (illustrativ β token+internet kerak): Telefonda bot "Yangi vazifa nomini yozing" deydi, siz yozasiz, ro'yxatda tugmali xabarlar chiqadi, "β Bajarildi" bosilsa xabar tahrirlanadi. Biz buni real Telegram'da ishga tushirmadik (
@BotFathertoken + HTTPS/protsess kerak), lekin butun mantiq yuqoridagidek aniq tekshirilgan.
Yo'l xaritasi: shu botni o'zingiz qayta qurish¶
composer init,composer require nutgram/nutgram,composer require --dev phpunit/phpunit;src/ga PSR-4 autoload.Database+ ikki repozitoriy yozing (PDO,prepare/execute, egalik tekshiruviAND user_id = :uid).AddTaskconversation:start()-> savol ->saqlash()(validatsiya + DB). Repozitoriyni konteynerdan oling.Keyboards: reply menyu + shartli inlinetaskRow.- Middleware'lar (class-based):
RegisterUser,BlockBanned,Throttle,EnsureAdmin. Tartibni to'g'ri qo'ying (upsert -> ban). Bot::build($bot, $pdo, $admins)β hammasini ulang;onCommand+onTextbilan menyu,onCallbackQueryData('done:{id}'), admingroup()+gate,fallback.- Kirish nuqtalari:
bot.php(polling) vawebhook.php(HTTPS). Token.env/getenvdan. tests/BotTest.php:Nutgram::fake()+:memory:SQLite,setCommonUser/Chat,willStartConversationβ har imkoniyatni offline tekshiring.- Deploy: kichik bot β polling + systemd; production β webhook + Redis cache (13, 17).
Mashqlar¶
Oson¶
Bot::buildda yangi/aboutbuyrug'i qo'shing: bot nomi va versiyasini yuborsin. FakeNutgram bilanassertReplyTextorqali tekshiring.UserRepositorygarecent(int $limit): arraymetodi qo'shing (ORDER BY created_at DESC LIMIT). Sof PDO,:memory:DB bilan ikki foydalanuvchi qo'shib tekshiring.- Reply menyuga uchinchi tugma "βΉοΈ Yordam" qo'shing va uni
/helpbilan bir xil closure'ga (onText) ulang. taskRow()ga uchinchi tugma "βοΈ Tahrirlash" (callback_data: "edit:$id") qo'shing (hozircha faqat tugma β handler keyingi mashqda). Tugmalar soni 3 bo'lganini test bilan tasdiqlang.Throttleni/listga handler-middleware sifatida biriktiring (->middleware(new Throttle(2))). Birinchi/listo'tishi, darhol takror jim qolishini FakeNutgram'daassertNoReplybilan tekshiring.Database::migrateni alohida ishga tushirib (:memory:),usersvatasksjadvallari yaratilganiniSELECT name FROM sqlite_masterbilan tasdiqlang.
O'rta¶
- Vazifani tahrirlash:
edit:{id}callback qo'shing βAddTaskga o'xshash kichik conversation (EditTask) boshlab, yangi nomni so'rab,TaskRepository::rename($id, $userId, $title)(egalik tekshiruvi bilan) yangilasin. Offline conversation + DB bilan tekshiring. - Filtr:
/doneva/pendingbuyruqlarini qo'shing β mos ravishda faqat bajarilgan/bajarilmagan vazifalarni ko'rsatsin (listForUserga?bool $doneparametri qo'shing). DB testi yozing. - Throttle'ni global qiling: uni
Bot::buildichida$bot->middleware(new Throttle(1))sifatidaRegisterUser/BlockBanneddan keyin qo'ying. Tartib (upsert -> ban -> throttle -> repo) buzilmaganini, va bloklangan foydalanuvchi throttle'gacha yetib bormasligini trace bilan tushuntiring. - Admin broadcast (mantiq qismi):
/broadcast <matn>admin buyrug'i qo'shing βUserRepository::allIds()ni aylanib har biriga yuborishni simulyatsiya qilsin (haqiqiysendMessageo'rniga ID'lar ro'yxatini qaytaruvchi sof metod). Jonli yuborish illustrativ; ID yig'ish mantig'ini DB bilan tekshiring. - Bo'sh DB callback: mavjud bo'lmagan ID bilan
done:99999callback yuboring (DB bo'sh).answerCallbackQuery"Topilmadi" alerti chiqishini va hech narsa o'zgarmasligini tasdiqlang. /startdeeplink:Bot::builddastartpayload (/start ref_xyz) ni o'qib,usersgasourceustuni sifatida saqlang (migratsiyani yangilang). Deeplink mantig'ini DB bilan tekshiring (04-bob deeplink).
Qiyin¶
- Tranzaksiya: vazifa qo'shganda bir vaqtning o'zida
users.tasks_counthisoblagichini oshiring β ikkalasini bitta PDO tranzaksiyasi (beginTransaction/commit) ichida bajaring. Birortasi xato bersarollBackqilinishini (hech narsa yozilmasligini) test bilan tasdiqlang (../sql/README.md). - Sahifalash:
/listko'p vazifada uzun bo'ladi β har sahifada 5 ta vazifa ko'rsatib, "β¬ οΈ Oldingi"/"Keyingi β‘οΈ" inline tugmalari (page:{n}) bilan sahifalang (07-bob pagination). Sahifa hisoblash mantig'ini offline tekshiring. - To'liq integratsion oqim: bitta testda
/start-> "β Vazifa qo'shish" tugmasi -> nom ->/list->done:{id}callback ketma-ketligini ishga tushiring va har bosqichni (assertReplyText, DB holati) tekshiring.willStartConversationvasetCommonUser/Chatni to'g'ri ishlating. - Ko'p foydalanuvchi izolyatsiyasi (mantiq): ikki foydalanuvchi (100 va 200) o'z vazifalarini qo'shganda bir-birinikini ko'rmasligini DB darajasida tasdiqlang (
listForUser(100)faqat 100 niki). Nutgram conversation cache kaliti (user+chat) buni qanday kafolatlashini, va FakeNutgram'da nega odatda bitta foydalanuvchi ketma-ketligini sinashimizni izohlang (08-bob).
Yechimlar
1-mashq.
$bot->onCommand('about', fn (Nutgram $bot) => $bot->sendMessage('Vazifa-menejer bot v1.0'));
// Test:
$bot = $this->fakeBot(555);
$bot->hearText('/about')->reply();
$bot->assertReplyText('Vazifa-menejer bot v1.0');
2-mashq.
public function recent(int $limit): array
{
$stmt = $this->pdo->prepare('SELECT id, first_name FROM users ORDER BY created_at DESC LIMIT :n');
$stmt->bindValue('n', $limit, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
// Test: ikki foydalanuvchi upsert qiling, recent(1) bitta qator qaytarishini tekshiring.
LIMIT da bindValue(..., PDO::PARAM_INT) ishlating β LIMIT ga string bog'lash ba'zi drayverlarda xato beradi.
3-mashq.
$mainMenu = ReplyKeyboardMarkup::make(resize_keyboard: true)
->addRow(KeyboardButton::make("\u{2795} Vazifa qo'shish"), KeyboardButton::make("\u{1F4CB} Vazifalarim"))
->addRow(KeyboardButton::make("\u{2139} Yordam"));
$help = fn (Nutgram $bot) => $bot->sendMessage("Buyruqlar: /add /list");
$bot->onCommand('help', $help);
$bot->onText("\u{2139} Yordam", $help);
4-mashq.
public static function taskRow(array $task): InlineKeyboardMarkup
{
$id = $task['id'];
$buttons = [];
if (!$task['done']) {
$buttons[] = InlineKeyboardButton::make("\u{2705} Bajarildi", callback_data: "done:$id");
}
$buttons[] = InlineKeyboardButton::make("\u{270F} Tahrirlash", callback_data: "edit:$id");
$buttons[] = InlineKeyboardButton::make("\u{1F5D1} O'chirish", callback_data: "del:$id");
return InlineKeyboardMarkup::make()->addRow(...$buttons);
}
// Test: $this->assertCount(3, Keyboards::taskRow(['id'=>1,'done'=>0])->inline_keyboard[0]);
5-mashq.
use App\Middleware\Throttle;
$bot = $this->fakeBot(555);
// /list ga throttle biriktirilgan deb faraz qilamiz (Bot::build da):
// $bot->onCommand('list', $showList)->middleware(new Throttle(2));
$bot->hearText('/list')->reply();
$bot->assertReply('sendMessage'); // birinchi o'tdi (bo'sh ro'yxat xabari)
$bot->hearText('/list')->reply(); // darhol takror
$bot->assertNoReply(); // throttle jim qaytdi
Throttle getUserData ishlatgani uchun setCommonUser bilan bitta foydalanuvchi bo'lishi shart.
6-mashq.
$pdo = Database::connect('sqlite::memory:');
$names = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
->fetchAll(\PDO::FETCH_COLUMN);
assert($names === ['tasks', 'users']);
7-mashq.
// TaskRepository:
public function rename(int $id, int $userId, string $title): int
{
$stmt = $this->pdo->prepare('UPDATE tasks SET title = :t WHERE id = :id AND user_id = :uid');
$stmt->execute(['t' => $title, 'id' => $id, 'uid' => $userId]);
return $stmt->rowCount();
}
// EditTask conversation:
class EditTask extends Conversation
{
public ?int $taskId = null;
public function start(Nutgram $bot, int $taskId = 0) // begin'dan $data -> $taskId
{
$this->taskId = $taskId;
$bot->sendMessage('Yangi nomni yozing:');
$this->next('saqlash');
}
public function saqlash(Nutgram $bot)
{
$title = trim((string) $bot->message()->text);
if ($title === '') { $bot->sendMessage('Bo\'sh bo\'lmasin:'); return; }
$tasks = $bot->getContainer()->get(\App\TaskRepository::class);
$changed = $tasks->rename($this->taskId, $bot->userId(), $title);
$bot->sendMessage($changed ? "Yangilandi: $title" : 'Topilmadi yoki sizniki emas.');
$this->end();
}
}
// callback: $bot->onCallbackQueryData('edit:{id}', fn (Nutgram $b, int $id) => EditTask::begin($b, data: [$id]));
begin($bot, data: [$id]) dagi $data massivi start(Nutgram $bot, int $taskId) ga uzatiladi (08-bob, 15-mashq). Offline: edit conversation -> nom -> DB rename ni listForUser bilan tekshiring.
8-mashq.
public function listForUser(int $userId, ?bool $done = null): array
{
$sql = 'SELECT id, title, done FROM tasks WHERE user_id = :uid';
$params = ['uid' => $userId];
if ($done !== null) { $sql .= ' AND done = :d'; $params['d'] = $done ? 1 : 0; }
$sql .= ' ORDER BY done ASC, id ASC';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// /done -> listForUser($uid, true), /pending -> listForUser($uid, false)
markDone, listForUser($uid, true) bittasini, false ikkinchisini qaytarishini tasdiqlang.
9-mashq. Tartib Bot::build da:
$bot->middleware(new RegisterUser($users)); // 1 β eng tashqi
$bot->middleware(new BlockBanned($users)); // 2
$bot->middleware(new Throttle(1)); // 3
$bot->middleware(/* repo -> konteyner */); // 4
BlockBanned (2) da short-circuit bo'ladi β $next chaqirilmaydi, demak Throttle (3) umuman ishlamaydi. Bu to'g'ri: bloklangan odamga throttle hisoblash keraksiz. Trace bilan: ban holatida faqat register -> block qayd qilinadi, throttle yo'q. (09-bob β onion modeli: tashqi qatlam ichkilarini o'rab oladi.)
10-mashq.
// UserRepository:
public function allIds(): array
{
return array_map('intval', $this->pdo->query('SELECT id FROM users WHERE is_banned = 0')
->fetchAll(\PDO::FETCH_COLUMN));
}
// Sof "kimga yuborardik" mantig'i (jonli yuborish illustrativ):
function broadcastTargets(\App\UserRepository $users): array { return $users->allIds(); }
allIds() 2 ta (bloklanmagan) qaytarishini tasdiqlang. Real sendMessage tsikli β jonli (rate-limit'ga e'tibor, 15-bob), shuning uchun illustrativ.
11-mashq.
$bot = $this->fakeBot(555); // DB bo'sh
$bot->hearCallbackQueryData('done:99999')->reply();
$bot->assertCalled('answerCallbackQuery'); // "Topilmadi yoki sizniki emas." alerti
$this->assertSame(0, (new TaskRepository($this->pdo))->countAll()); // hech narsa qo'shilmadi/o'zgarmadi
markDone(99999, 555) rowCount() === 0 qaytaradi -> else shoxi -> alert. DB o'zgarmaydi.
12-mashq.
// migratsiyaga: ALTER ... yoki users jadvaliga 'source TEXT' ustuni.
$bot->onCommand('start {payload}', function (Nutgram $bot, ?string $payload = null) use ($users) {
if ($payload) { $users->setSource($bot->userId(), $payload); }
$bot->sendMessage('Salom!', reply_markup: Keyboards::mainMenu());
});
// setSource: UPDATE users SET source = :s WHERE id = :id
hearText('/start ref_xyz') -> users.source === 'ref_xyz'. Eslatma: oddiy /start (payloadsiz) da $payload null bo'ladi β handler ikkalasini ham qoplashi uchun nullable. (04-bob deeplink.)
13-mashq.
public function addWithCounter(int $userId, string $title): int
{
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare('INSERT INTO tasks (user_id, title, created_at) VALUES (:u,:t,:c)');
$stmt->execute(['u' => $userId, 't' => $title, 'c' => gmdate('c')]);
$id = (int) $this->pdo->lastInsertId();
$this->pdo->prepare('UPDATE users SET tasks_count = tasks_count + 1 WHERE id = :id')
->execute(['id' => $userId]);
$this->pdo->commit();
return $id;
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
users da tasks_count ustuni bo'lsin; muvaffaqiyatda vazifa + hisoblagich oshadi; sun'iy xato (masalan yo'q ustun) tashlansa rollBack -> tasks bo'sh qoladi. (../sql/README.md tranzaksiyalar.)
14-mashq.
function pageSlice(array $rows, int $page, int $perPage = 5): array
{
return array_slice($rows, ($page - 1) * $perPage, $perPage);
}
function totalPages(int $count, int $perPage = 5): int
{
return max(1, (int) ceil($count / $perPage));
}
// inline: page:{n} callback -> shu sahifani qayta chizish. Tugmalar:
// [β¬
οΈ Oldingi] (page>1), [Keyingi β‘οΈ] (page<totalPages)
perPage=5 -> totalPages = 3; pageSlice($rows, 3) 2 ta qaytaradi. Jonli editMessageText bilan sahifa almashinuvi β illustrativ (07-bob).
15-mashq.
$bot = $this->fakeBot(555);
$bot->willStartConversation();
$bot->hearText('/start')->reply();
$bot->assertReply('sendMessage'); // salom + menyu
$bot->hearText("\u{2795} Vazifa qo'shish")->reply();
$bot->assertReplyText("Yangi vazifa nomini yozing (yoki /cancel):");
$bot->hearText('Sport')->reply();
$bot->assertReplyText("\u{2705} Qo'shildi: Sport");
$bot->assertNoConversation();
$tasks = new TaskRepository($this->pdo);
$id = $tasks->listForUser(555)[0]['id'];
$bot->hearText('/list')->reply();
$bot->assertReply('sendMessage'); // ro'yxatda vazifa
$bot->hearCallbackQueryData("done:$id")->reply();
$bot->assertCalled('answerCallbackQuery');
$this->assertSame(1, $tasks->find($id, 555)['done']); // bajarildi
willStartConversation (conversation) va setCommonUser/Chat (fakeBot ichida) ikkalasi ham shart.
16-mashq.
$tasks = new TaskRepository($this->pdo);
$tasks->add(100, 'A100');
$tasks->add(200, 'A200');
$this->assertCount(1, $tasks->listForUser(100));
$this->assertSame('A100', $tasks->listForUser(100)[0]['title']);
$this->assertCount(1, $tasks->listForUser(200));
WHERE user_id = :uid bilan ta'minlanadi. Conversation darajasida esa Nutgram cache kalitini user+chat dan tuzadi β shuning uchun 100 va 200 ning yarim qolgan formalari ham aralashmaydi. FakeNutgram'da willStartConversation() faqat bitta (oxirgi) foydalanuvchini eslab qoladi, shu sababli offline testda odatda bitta foydalanuvchi ketma-ketligini sinaymiz; ikki foydalanuvchini ajratish β jonli cache kaliti vazifasi (08-bob).
β¬ οΈ Oldingi: 17 β Production va deploy Β· π README Β· Keyingi: 19 β Guruhlarda ishlash β‘οΈ