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.
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#
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:
# 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.",
}
# bot/locals/ru.py
STRINGS = {
"choose_category": "Выберите раздел:",
"products": "Товары:",
"back": "← Назад",
"price": "Цена: {price} сум",
"catalog_error": "Каталог временно недоступен, попробуйте позже.",
}
# 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:
# 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:
# 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:
# 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:
-
/startfaqat birinchi kirishda til so'raydi. Til bazada bo'lsa, handlerSkipHandlerbilan voz kechadi va/startni 2-qismdagi navigatsiya router'i bosh ekran bilan davom ettiradi. Keyinchalik tilni/languagebuyrug'i bilan almashtirish mumkin; -
til tanlangach darhol bosh ekran chiqadi. Faqat "Bo'limni tanlang:" matnini tahrirlab qo'ysak, foydalanuvchi tugmasiz xabar oldida qolardi;
-
langro'yxatdan tekshiriladi.callback_datani o'zgartirilgan mijoz ham yubora oladi — bazagalang:xxkabi 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:
# 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:
# 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):
# 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}")]
]
)
# 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:
# 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#
# 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"
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.