Asosiy kontentga o‘tish

Maqolalar

Maqola · 2026-08-16 · ~8 daqiqa o‘qiladi · o'rta

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

#python #aiogram #telegram-bot #ux #callback

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:

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.

bot/
├── keyboards/
│   └── catalog.py      ← tugma quruvchilar
├── handlers/
│   └── navigation.py   ← ekranlar orasida yurish
└── utils/
    └── screens.py      ← xavfsiz tahrirlash yordamchisi
# 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#

# bot/handlers/navigation.py
from aiogram import F, Router, types

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

router = Router()


@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 = callback.data.split(":")
    product = await catalog.product(int(product_id))
    text = f"<b>{product['name']}</b>\nNarxi: {product['price']} so'm"
    await show_screen(callback, text, product_card_kb(int(category_id)))

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.

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:

# 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:

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. Ularni alohida funksiyaga chiqarib testlash arziydi:

# tests/test_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)


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


def test_broken_callback_raises():
    import pytest

    with pytest.raises(ValueError):
        parse_product_cb("cat:5")

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.