Asosiy kontentga o‘tish

Maqolalar 12 daqiqa 37 ko‘rish

Ichma-ich tugmalar va «Ortga»: botda adashtirmaydigan navigatsiya

Kategoriya ichiga kirdingiz, tovarni ochdingiz — endi ortga qanday qaytasiz? Bot navigatsiyasining butun siri callback_data'ni to'g'ri loyihalashda. Ekran tushunchasi, 64 baytlik chegara va «xabarni tahrirlash» hiylasi — Aladdin botidagi ishlagan yechim.

telegram-bot python

Mundarija

Avvalgi qismda katalogni backend'dan oladigan bo'ldik. Endi uni foydalanuvchiga ko'rsatish kerak: bosh menyudan kategoriyaga kiradi, ro'yxatdan tovarni ochadi, ko'rib bo'lgach ortga qaytadi. Sayt bo'lsa brauzerning o'zi "Back" tugmasini berardi. Botda esa bunday tugma yo'q — uni o'zimiz qurishimiz kerak, va aynan shu joyda ko'p botlar chalkashib ketadi: "Ortga" bosilsa boshqa joyga olib boradi, yoki umuman ishlamaydi.

Asosiy g'oya: har tugma o'zi bilan manzil olib yuradi#

Telegram'da inline tugma bosilganda botga callback_data degan qisqa satr keladi. Navigatsiyaning butun siri shu satrni to'g'ri loyihalashda. Qoida sodda: callback ichida foydalanuvchi qayerga borishi VA qayerdan kelgani yozilgan bo'lsin. Shunda "Ortga" tugmasi hech qachon adashmaydi, chunki manzil tugmaning o'zida turibdi:

Misol
Bosh menyu          →  callback: "menu"
Kategoriya 5        →  callback: "cat:5"
Tovar 12 (5-katdan) →  callback: "prod:12:5"   ← qayerdan kelgani ham ichida

Tovar ekranidagi "Ortga" tugmasi cat:5 deb yoziladi — ya'ni u shunchaki "ortga" emas, "5-kategoriyaga qaytish" tugmasi. Foydalanuvchi qaysi yo'l bilan kelgan bo'lsa, o'sha yo'ldan qaytadi.

Nega holatda saqlamaymiz?

Yo'lni FSM'da yoki bazada saqlash ham mumkin edi. Lekin callback'ning o'zida saqlash bitta katta afzallik beradi: xabar eskirsa ham ishlaydi. Foydalanuvchi bir hafta oldingi xabardagi tugmani bossa, bot hech qanday holatga qaramay to'g'ri ekranni ochadi. Holatga bog'langan navigatsiya esa "eski tugma bosildi — bot adashdi" degan muammoni olib keladi.

Ekran tushunchasi: yangi xabar emas, tahrirlash#

Ikkinchi muhim qaror — har bosishda yangi xabar yubormaslik. Yangi xabar yuborilsa, chat uch daqiqada menyularning qabristoniga aylanadi. To'g'ri yo'l: bitta xabarni ekran deb qarab, tugma bosilganda o'sha xabarning o'zini tahrirlash. Foydalanuvchi go'yo kichik ilova ichida yurgandek his qiladi.

Fayl tuzilishi
bot/
├── keyboards/
│   └── catalog.py      ← tugma quruvchilar
├── handlers/
│   └── navigation.py   ← ekranlar orasida yurish
├── utils/
│   ├── callbacks.py    ← callback satrlarini ajratish
│   └── screens.py      ← xavfsiz tahrirlash yordamchisi
└── app.py              ← botni ishga tushirish
Python
# bot/keyboards/catalog.py
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup


def categories_kb(categories: list[dict]) -> InlineKeyboardMarkup:
    rows = [
        [InlineKeyboardButton(text=c["name"], callback_data=f"cat:{c['id']}")] for c in categories
    ]
    return InlineKeyboardMarkup(inline_keyboard=rows)


def products_kb(products: list[dict], category_id: int) -> InlineKeyboardMarkup:
    rows = [
        # tovar tugmasi qayerdan ochilganini ham eslab qoladi
        [InlineKeyboardButton(text=p["name"], callback_data=f"prod:{p['id']}:{category_id}")]
        for p in products
    ]
    # "Ortga" — bu yerda bosh menyuga
    rows.append([InlineKeyboardButton(text="← Ortga", callback_data="menu")])
    return InlineKeyboardMarkup(inline_keyboard=rows)


def product_card_kb(category_id: int) -> InlineKeyboardMarkup:
    # tovardan ortga — aynan o'sha kategoriyaga
    return InlineKeyboardMarkup(
        inline_keyboard=[[InlineKeyboardButton(text="← Ortga", callback_data=f"cat:{category_id}")]]
    )

Handler'lar: uch ekran, uch marshrut#

navigation.py 1-qismdagi handlers/products.py ning o'rnini egallaydi — cat: tugmasini endi shu yerdagi screen_category ushlaydi, shuning uchun ikkalasini bir vaqtda ulamang. parse_product_cb esa pastdagi test bo'limida yoziladi.

Python
# bot/handlers/navigation.py
import logging

from aiogram import F, Router, html, types
from aiogram.filters import CommandStart, ExceptionTypeFilter

from bot.api.catalog import CatalogUnavailable, catalog
from bot.keyboards.catalog import categories_kb, product_card_kb, products_kb
from bot.utils.callbacks import parse_product_cb
from bot.utils.screens import show_screen

router = Router()
logger = logging.getLogger(__name__)


@router.message(CommandStart())
async def start(message: types.Message):
    # birinchi "ekran" — keyingi hamma bosishlar shu xabarni tahrirlaydi
    categories = await catalog.categories()
    await message.answer("Bo'limni tanlang:", reply_markup=categories_kb(categories))


@router.callback_query(F.data == "menu")
async def screen_menu(callback: types.CallbackQuery):
    categories = await catalog.categories()
    await show_screen(callback, "Bo'limni tanlang:", categories_kb(categories))


@router.callback_query(F.data.startswith("cat:"))
async def screen_category(callback: types.CallbackQuery):
    category_id = int(callback.data.split(":")[1])
    products = await catalog.products(category_id)
    await show_screen(callback, "Tovarlar:", products_kb(products, category_id))


@router.callback_query(F.data.startswith("prod:"))
async def screen_product(callback: types.CallbackQuery):
    product_id, category_id = parse_product_cb(callback.data)
    product = await catalog.product(product_id)
    # backend'dan kelgan matn HTML ichiga ekranlab qo'yiladi: nomdagi "&" yoki "<"
    # Telegram'ning HTML tahlilchisini sindirmasin
    text = (
        f"{html.bold(html.quote(product['name']))}\nNarxi: {html.quote(str(product['price']))} so'm"
    )
    await show_screen(callback, text, product_card_kb(category_id))


@router.error(ExceptionTypeFilter(CatalogUnavailable))
async def catalog_down(event: types.ErrorEvent):
    # 1-qismdagi try/except'ni har handler'da takrorlamaymiz: shu router'dagi
    # istalgan ekran backend yotganda shu yerga tushadi
    logger.error("Katalog API javob bermadi: %s", event.exception)
    text = "Katalog vaqtincha ishlamayapti, birozdan keyin urinib ko'ring."
    if event.update.callback_query:
        # alert ko'rsatadi va tugmadagi "yuklanmoqda" belgisini ham yopadi
        await event.update.callback_query.answer(text, show_alert=True)
    elif event.update.message:
        await event.update.message.answer(text)

E'tibor bering, har handler o'z ekranini butunlay o'zi chizadi va boshqa ekranlar haqida hech narsa bilmaydi. Navigatsiya mantiqi tugmalarning ichida ketgani uchun handler'lar bir-biriga bog'lanib qolmaydi.

Uchta qator alohida izohga arziydi:

  • /start — birinchi ekranni yuboradi. Qolgan hamma ekranlar callback orqali shu xabarni tahrirlaydi; bu handler bo'lmasa, bot foydalanuvchiga umuman hech narsa ko'rsatmaydi;

  • html.quote — tovar nomi backend'dan keladi va parse_mode="HTML" bilan yuboriladi. Telegram HTML'da teg yoki entity bo'lmagan har bir <, > va & ekranlanishini talab qiladi, aks holda xabarni rad etadi. "Changyutgich & Max" kabi nom ekranni sindirmasligi uchun matn &lt;Pro&gt; &amp; ko'rinishida yuboriladi;

  • @router.error — 1-qismdagi "backend yotsa" javobini har handler'da try/except bilan takrorlamaymiz. Shu router'dagi istalgan ekranda CatalogUnavailable chiqsa, xato shu yerga tushadi: tugma bosilgan bo'lsa, foydalanuvchi alert ko'radi va tugmadagi "yuklanmoqda" belgisi yopiladi, /start bo'lsa, oddiy xabar oladi.

Router'ni ishga tushiradigan fayl:

Python
# bot/app.py
import asyncio
import logging
import os

from aiogram import Bot, Dispatcher

from bot.handlers import navigation


async def main() -> None:
    logging.basicConfig(level=logging.INFO)
    bot = Bot(os.environ["BOT_TOKEN"])
    dp = Dispatcher()
    dp.include_router(navigation.router)
    await dp.start_polling(bot)


if __name__ == "__main__":
    asyncio.run(main())

Loyiha ildizidan (bot/ papka turgan joydan) paket sifatida ishga tushiriladi:

Terminal
BOT_TOKEN=... python -m bot.app

python bot/app.py deb ishga tushirsangiz, Python bot/ ichidan boshlaydi va from bot.handlers ... qatori ModuleNotFoundError: No module named 'bot' bilan yiqiladi.

Xavfsiz tahrirlash: mayda, lekin muhim ikki hiyla#

edit_textni to'g'ridan-to'g'ri chaqirsangiz, ikkita mayda muammoga duch kelasiz. Ikkalasini bitta yordamchiga yig'ib qo'yamiz:

Python
# bot/utils/screens.py
from aiogram import types
from aiogram.exceptions import TelegramBadRequest


async def show_screen(callback: types.CallbackQuery, text: str, keyboard) -> None:
    # 1-hiyla: answer() bo'lmasa, tugma ustida "soat" aylanaveradi —
    # Telegram bot javob berganini bilishi kerak
    await callback.answer()
    try:
        await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
    except TelegramBadRequest as exc:
        # 2-hiyla: foydalanuvchi bir tugmani ikki marta bossa, Telegram
        # "message is not modified" xatosini beradi. Bu haqiqiy xato emas —
        # ekran allaqachon kerakli holatda. Jimgina o'tkazib yuboramiz
        if "message is not modified" not in str(exc):
            raise

64 bayt — callback_data chegarasi

Telegram callback_dataga ko'pi bilan 64 bayt sig'diradi. prod:12:5 bemalol sig'adi, lekin yo'l chuqurlashib prod:12:cat:5:page:3:sort:price ga aylansa, chegaraga urilasiz — va Telegram tugmani jo'natishda xato beradi. Yo'l uzayib ketsa, qisqartma yozuv o'ylang (masalan p:12:5:3) yoki chuqur holatni bazada saqlab, callback'da faqat kalitini yuboring.

Rasmli ekranlar: tahrirlashning bitta istisnosi#

Tovar kartasida rasm bo'lsa, ish biroz o'zgaradi: matnli xabarni rasmli xabarga edit_text bilan aylantirib bo'lmaydi, chunki Telegram uchun bular har xil turdagi xabarlar. Bunda ikki yo'l bor: rasm o'rniga havolali matn ishlatish (eng sodda) yoki edit_media bilan rasmni almashtirib borish:

Python
from aiogram.types import InputMediaPhoto

# rasmli ekranlar orasida yurish: matn emas, media tahrirlanadi
await callback.message.edit_media(
    InputMediaPhoto(media=product["image_url"], caption=text, parse_mode="HTML"),
    reply_markup=product_card_kb(category_id),
)

Aladdin'da har tovarning rasmi bor, shuning uchun katalog ekranlari boshidanoq rasmli xabar qilib qurilgan — shunda edit_media butun yo'l davomida ishlayveradi.

Test: marshrut satrlarini tekshiramiz#

Navigatsiyada eng ko'p sinadigan narsa — callback satrlarini yig'ish va ajratish. Shuning uchun ajratish alohida funksiyada turadi — screen_product aynan shu funksiyadan foydalanadi, test esa uni tekshiradi:

Python
# bot/utils/callbacks.py
def parse_product_cb(data: str) -> tuple[int, int]:
    """'prod:12:5' -> (12, 5); format buzuq bo'lsa ValueError."""
    prefix, product_id, category_id = data.split(":")
    if prefix != "prod":
        raise ValueError(data)
    return int(product_id), int(category_id)
Python
# tests/test_callbacks.py
import pytest

from bot.utils.callbacks import parse_product_cb


def test_product_callback_roundtrip():
    assert parse_product_cb("prod:12:5") == (12, 5)


def test_broken_callback_raises():
    with pytest.raises(ValueError):
        parse_product_cb("cat:5")
Terminal
python -m pytest tests -q

Oddiy pytest emas, python -m pytest: bu shakl joriy papkani import yo'liga qo'shadi va test bot paketini topadi. Busiz ModuleNotFoundError: No module named 'bot' chiqadi.

Xulosa#

Botda "Ortga" tugmasi sehr emas, oddiy intizom: har tugma o'zining to'liq manzilini callback ichida olib yuradi, ekranlar yangi xabar o'rniga mavjudini tahrirlaydi, answer() esa hech qachon unutilmaydi. Shu uch odat bilan katalog qancha chuqur bo'lsa ham foydalanuvchi adashmaydi — Aladdin'da bu yechim besh yildan beri minglab odamga xizmat qilyapti.

Keyingi qismda botni uch tilda gapirtiramiz: tarjima fayllari, til tanlash va "kontent bir tilda, interfeys boshqa tilda" muammosining yechimi.

12 daqiqa 37 marta o‘qildi