Asosiy kontentga o‘tish

Maqolalar 19 daqiqa 122 ko‘rish

Bot uch tilda gapiradi: i18n'ni murakkablashtirmasdan qurish

O'zbekiston bozorida bot kamida ikki, ko'pincha uch tilda ishlashi kerak. Gettext'siz, oddiy lug'atlar bilan ishlaydigan yechim: interfeys tarjimalari, foydalanuvchi tilini eslab qolish va backend'dan keladigan kontentni to'g'ri tilda ko'rsatish.

telegram-bot python

Mundarija

O'zbekiston bozori uchun yozilgan bot amalda kamida ikki tilda gapirishi kerak, Aladdin botida esa uchta til bor: o'zbek, rus va ingliz. Ko'p tillilikni birinchi marta qo'shayotgan dasturchi odatda gettext, .po fayllar va kompilyatsiya dunyosiga kirib ketadi — vaholanki bot uchun bundan ancha sodda yo'l bor va u yillab bemalol xizmat qiladi. Aladdin'da ishlatilgan yondashuv aynan shunday: oddiy Python lug'atlari.

Boshlashdan oldin bitta muhim ajratishni aniqlab olaylik. Botdagi matnlar ikki xil bo'ladi: interfeys matnlari (tugmalar, xizmat xabarlari — ular kodda yashaydi) va kontent (tovar nomlari, tavsiflar — ular backend'dan keladi). Ikkalasiga yechim boshqa-boshqa, quyida ikkalasini ham ko'ramiz.

1-qadam: tarjima lug'atlari#

Fayl tuzilishi
bot/
├── locals/
│   ├── __init__.py     ← t() funksiyasi
│   ├── uz.py
│   ├── ru.py
│   └── en.py
├── middlewares/
│   ├── __init__.py
│   └── language.py     ← har handler'ga tilni yetkazadi
├── handlers/
│   └── language.py     ← til tanlash ekrani
├── utils/
│   └── content.py      ← backend kontentidan kerakli tilni olish
└── db.py               ← foydalanuvchi tilini saqlash

Har til — bitta oddiy lug'at. Kalitlar inglizcha va ma'noli bo'lsin, chunki kod ichida aynan kalit ko'rinadi:

Python
# bot/locals/uz.py
STRINGS = {
    "choose_category": "Bo'limni tanlang:",
    "products": "Tovarlar:",
    "back": "← Ortga",
    "price": "Narxi: {price} so'm",
    "catalog_error": "Katalog vaqtincha ishlamayapti, birozdan keyin urinib ko'ring.",
}
Python
# bot/locals/ru.py
STRINGS = {
    "choose_category": "Выберите раздел:",
    "products": "Товары:",
    "back": "← Назад",
    "price": "Цена: {price} сум",
    "catalog_error": "Каталог временно недоступен, попробуйте позже.",
}
Python
# bot/locals/en.py
STRINGS = {
    "choose_category": "Choose a section:",
    "products": "Products:",
    "back": "← Back",
    "price": "Price: {price} UZS",
    "catalog_error": "The catalog is temporarily unavailable, please try again later.",
}

Endi ularni bitta funksiya ortiga yig'amiz. Eng muhim joyi — fallback zanjiri: tarjima topilmasa bot yiqilmaydi, o'zbekchasini ko'rsatadi:

Python
# bot/locals/__init__.py
from . import en, ru, uz

LANGUAGES = {"uz": uz.STRINGS, "ru": ru.STRINGS, "en": en.STRINGS}
DEFAULT = "uz"


def t(key: str, lang: str, **kwargs) -> str:
    """Tarjimani qaytaradi; topilmasa o'zbekchaga, u ham bo'lmasa kalitga tushadi.

    Yangi til qo'shilganda hamma satr birdan tarjima qilinmagan bo'lishi
    normal holat — shunda foydalanuvchi bo'sh joy emas, hech bo'lmasa
    o'zbekcha matnni ko'radi.
    """
    table = LANGUAGES.get(lang, LANGUAGES[DEFAULT])
    text = table.get(key) or LANGUAGES[DEFAULT].get(key) or key
    return text.format(**kwargs) if kwargs else text

Tarjima yetishmasa jim qolmasin

t() kalitni topolmasa kalitning o'zini qaytaradi — bu ataylab qilingan: ekranda catalog_error degan "xom" yozuv chiqsa, xato bir qarashda ko'rinadi va tez tuzatiladi. Bo'sh satr qaytarilsa, muammo oylab sezilmay yurishi mumkin edi.

2-qadam: foydalanuvchi tilini eslab qolish#

Til — sessiya emas, doimiy tanlov. Shuning uchun uni xotirada emas, foydalanuvchi yozuvida saqlaymiz. Aladdin'da bu SQLite'dagi jadval — handler'lar unga kichik qatlam orqali murojaat qiladi:

Python
# bot/db.py
import sqlite3
from dataclasses import dataclass

DB_PATH = "bot.sqlite3"


@dataclass
class User:
    id: int
    language: str


class Users:
    """Foydalanuvchi yozuvini o'qish/yozish qatlami — handler'lar SQL'ni bilmaydi."""

    def __init__(self, path: str) -> None:
        self.conn = sqlite3.connect(path)
        self.conn.execute(
            "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, language TEXT)"
        )

    def get(self, user_id: int) -> User | None:
        row = self.conn.execute(
            "SELECT id, language FROM users WHERE id = ?", (user_id,)
        ).fetchone()
        return User(*row) if row else None

    def update(self, user_id: int, language: str) -> None:
        # bor bo'lsa yangilaydi, yo'q bo'lsa yaratadi
        self.conn.execute(
            "INSERT INTO users (id, language) VALUES (?, ?) "
            "ON CONFLICT(id) DO UPDATE SET language = excluded.language",
            (user_id, language),
        )
        self.conn.commit()


users = Users(DB_PATH)

Til tanlash ekrani:

Python
# bot/handlers/language.py
from aiogram import F, Router, types
from aiogram.dispatcher.event.bases import SkipHandler
from aiogram.filters import Command, CommandStart
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup

from bot.api.catalog import catalog
from bot.db import users
from bot.keyboards.catalog import categories_kb
from bot.locals import LANGUAGES, t
from bot.utils.screens import show_screen

router = Router()

LANG_PROMPT = "Tilni tanlang / Выберите язык / Choose language:"
LANG_KB = InlineKeyboardMarkup(
    inline_keyboard=[
        [
            InlineKeyboardButton(text="O'zbekcha", callback_data="lang:uz"),
            InlineKeyboardButton(text="Русский", callback_data="lang:ru"),
            InlineKeyboardButton(text="English", callback_data="lang:en"),
        ]
    ]
)


@router.message(CommandStart())
async def start(message: types.Message):
    if users.get(message.from_user.id):
        # til allaqachon tanlangan — /start'ni 2-qismdagi navigatsiya davom ettirsin
        raise SkipHandler
    # birinchi kirish: avval til
    await message.answer(LANG_PROMPT, reply_markup=LANG_KB)


@router.message(Command("language"))
async def change_language(message: types.Message):
    await message.answer(LANG_PROMPT, reply_markup=LANG_KB)


@router.callback_query(F.data.startswith("lang:"))
async def set_language(callback: types.CallbackQuery):
    lang = callback.data.split(":")[1]
    if lang not in LANGUAGES:
        # callback_data'ni o'zgartirilgan mijoz ham yubora oladi — bazaga axlat yozmaymiz
        await callback.answer()
        return
    users.update(callback.from_user.id, language=lang)
    # til tanlangach darhol bosh ekran: tugmalarsiz "Bo'limni tanlang" qoldirmaymiz
    categories = await catalog.categories()
    await show_screen(callback, t("choose_category", lang), categories_kb(categories, lang))

Uch joyga e'tibor bering:

  • /start faqat birinchi kirishda til so'raydi. Til bazada bo'lsa, handler SkipHandler bilan voz kechadi va /start ni 2-qismdagi navigatsiya router'i bosh ekran bilan davom ettiradi. Keyinchalik tilni /language buyrug'i bilan almashtirish mumkin;

  • til tanlangach darhol bosh ekran chiqadi. Faqat "Bo'limni tanlang:" matnini tahrirlab qo'ysak, foydalanuvchi tugmasiz xabar oldida qolardi;

  • lang ro'yxatdan tekshiriladi. callback_data ni o'zgartirilgan mijoz ham yubora oladi — bazaga lang:xx kabi axlat yozilmasin.

Har handler'da bazadan tilni qo'lda o'qib yurmaslik uchun kichik middleware yozamiz — u tilni bir marta olib, handler'ga tayyor uzatadi:

Python
# bot/middlewares/language.py
from aiogram import BaseMiddleware

from bot.db import users
from bot.locals import DEFAULT


class LanguageMiddleware(BaseMiddleware):
    async def __call__(self, handler, event, data):
        user = users.get(event.from_user.id)
        # data'ga qo'shilgan narsa handler'ga argument bo'lib keladi
        data["lang"] = user.language if user else DEFAULT
        return await handler(event, data)

Middleware o'zi ishlamaydi — uni Dispatcher'ga ulash kerak. 2-qismdagi app.py shunday bo'ladi:

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

from aiogram import Bot, Dispatcher

from bot.handlers import language, navigation
from bot.middlewares.language import LanguageMiddleware


async def main() -> None:
    logging.basicConfig(level=logging.INFO)
    bot = Bot(os.environ["BOT_TOKEN"])
    dp = Dispatcher()
    # middleware xabarlar va tugma bosishlar uchun alohida ulanadi
    dp.message.middleware(LanguageMiddleware())
    dp.callback_query.middleware(LanguageMiddleware())
    # tartib muhim: /start avval til router'iga tushadi
    dp.include_routers(language.router, navigation.router)
    await dp.start_polling(bot)


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

Middleware dp.message va dp.callback_query ga ulanadi, chunki u event.from_user ni o'qiydi — bu maydon xabar va tugma bosishda bor. Router'lar tartibi ham muhim: language.router birinchi turmasa, /start ni navigatsiya router'i ushlab oladi va yangi foydalanuvchidan til so'ralmaydi.

Endi istalgan handler tilni shunchaki parametr sifatida oladi. 2-qismdagi tugmalar va ekranlar uch tilli ko'rinishga o'tadi — "← Ortga" endi t("back", lang), backend'dan kelgan nomlar esa 3-qadamdagi pick() bilan olinadi (interfeysning birorta burchagi tarjimasiz qolmasligi kerak):

Python
# bot/keyboards/catalog.py
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup

from bot.locals import t
from bot.utils.content import pick


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


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


def product_card_kb(category_id: int, lang: str) -> InlineKeyboardMarkup:
    # tovardan ortga — aynan o'sha kategoriyaga
    return InlineKeyboardMarkup(
        inline_keyboard=[
            [InlineKeyboardButton(text=t("back", lang), callback_data=f"cat:{category_id}")]
        ]
    )
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.db import users
from bot.keyboards.catalog import categories_kb, product_card_kb, products_kb
from bot.locals import DEFAULT, t
from bot.utils.callbacks import parse_product_cb
from bot.utils.content import pick
from bot.utils.screens import show_screen

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


@router.message(CommandStart())
async def start(message: types.Message, lang: str):
    categories = await catalog.categories()
    await message.answer(t("choose_category", lang), reply_markup=categories_kb(categories, lang))


@router.callback_query(F.data == "menu")
async def screen_menu(callback: types.CallbackQuery, lang: str):
    categories = await catalog.categories()
    await show_screen(callback, t("choose_category", lang), categories_kb(categories, lang))


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


@router.callback_query(F.data.startswith("prod:"))
async def screen_product(callback: types.CallbackQuery, lang: str):
    product_id, category_id = parse_product_cb(callback.data)
    product = await catalog.product(product_id)
    text = (
        f"{html.bold(html.quote(pick(product, 'name', lang)))}\n"
        f"{t('price', lang, price=html.quote(str(product['price'])))}"
    )
    await show_screen(callback, text, product_card_kb(category_id, lang))


@router.error(ExceptionTypeFilter(CatalogUnavailable))
async def catalog_down(event: types.ErrorEvent):
    logger.error("Katalog API javob bermadi: %s", event.exception)
    update = event.update
    who = (update.callback_query or update.message).from_user
    # middleware bergan lang xato handler'iga yetib kelmaydi — bazadan o'zimiz olamiz
    user = users.get(who.id)
    text = t("catalog_error", user.language if user else DEFAULT)
    if update.callback_query:
        await update.callback_query.answer(text, show_alert=True)
    elif update.message:
        await update.message.answer(text)

catalog_down tilni lang parametridan emas, bazadan o'qiydi: sinovda middleware qo'shgan qiymat xato handler'iga yetib kelmadi — aiogram xato handler'ini boshqa kontekstda chaqiradi.

3-qadam: kontent tili — backend bilan kelishuv#

Interfeys hal bo'ldi, endi tovar nomlari. Ular bot kodida emas, backend bazasida turadi, demak tarjima ham o'sha yerda bo'lishi kerak. Amalda ikki keng tarqalgan sxema bor:

Ustunlar bilan: name_uz, name_ru, name_en — sodda, admin-panelda uchta maydon ko'rinadi, menejer uchun tushunarli. Aladdin shu yo'ldan borgan.

JSONField bilan: name = {"uz": ..., "ru": ..., "en": ...} — Metro loyihasida shu tanlangan, sabablarini mobil backend haqidagi maqolada yozganman.

Qaysi biri bo'lmasin, botga qulayi — backend hamma tilni birdan qaytarsin, tanlashni bot qilsin:

Python
# bot/utils/content.py
def pick(obj: dict, field: str, lang: str) -> str:
    """Backend javobidan kerakli til maydonini oladi: pick(product, "name", "ru").

    Fallback zanjiri interfeys bilan bir xil: so'ralgan til → o'zbekcha → bo'sh.
    Yarim tarjima qilingan tovar rus tilida bo'sh ko'rinmasligi kerak.
    """
    return obj.get(f"{field}_{lang}") or obj.get(f"{field}_uz") or ""

Nega tanlashni backend emas, bot qiladi? Chunki bitta so'rov bilan hamma til kelsa, botdagi 60 soniyalik kesh ham tilga bog'liq bo'lmay qoladi — uch til uchun uchta alohida kesh saqlash shart emas.

4-qadam: test#

Python
# tests/test_i18n.py
from bot.locals import t
from bot.utils.content import pick


def test_known_translation():
    assert t("back", "ru") == "← Назад"


def test_missing_lang_falls_back_to_uz():
    assert t("back", "de") == "← Ortga"


def test_missing_key_returns_key():
    assert t("no_such_key", "uz") == "no_such_key"


def test_content_fallback():
    product = {"name_uz": "Karusel", "name_ru": ""}
    assert pick(product, "name", "ru") == "Karusel"
Terminal
python -m pytest tests -q

Xulosa#

Bot uchun i18n'ning siri soddalikda: har til bitta lug'at, ularning ustida fallback'li t() funksiyasi, foydalanuvchi tili bazada, middleware esa uni handler'larga yetkazib turadi. Kontent tarjimasi backend'da yashaydi va hamma til bitta javobda keladi — tanlash botning ishi. Eng muhim qoida esa fallback: tarjima yetishmasa foydalanuvchi bo'sh ekran emas, hech bo'lmasa o'zbekcha matnni ko'rsin.

Seriyaning oxirgi qismida botni serverga chiqaramiz: systemd bilan 24/7 ishlash, yiqilganda o'zi turish va reboot'dan keyin ham avtomatik ko'tarilish.

19 daqiqa 122 marta o‘qildi