Asosiy kontentga o‘tish

Maqolalar 13 daqiqa 123 ko‘rish

aiogram FSM: ko'p bosqichli dialog-formalarni to'g'ri qurish

Telefon so'ra → SMS kodni kut → kabinetga kirit: bot dialoglari — holatlar mashinasi. aiogram 3'da FSM, Redis'da holat saqlash, har qadamda validatsiya, «qotib qolgan» foydalanuvchini qutqarish — Humo botining real patternlari.

telegram-bot database python

Mundarija

Avvalgi qismda erkin matnli AI-yo'lni ko'rdik. Endi "klassik yo'l" — qadam-baqadam forma. Humo botida bunday dialoglar bir nechta: login (telefon → SMS kod), tarif hisoblash (kompaniya → transport → o'lchamlar), murojaat qoldirish. Hammasining tagida bitta tushuncha yotadi: holatlar mashinasi (FSM — Finite State Machine).

Muammo: HTTP'da yo'q edi, botda bor#

Web-formada hamma maydon bitta sahifada keladi. Botda esa dialog vaqtga cho'zilgan: telefonni so'radik — foydalanuvchi ertaga javob berishi mumkin. Shu orada bot uchun u "qaysi qadamda turgani"ni eslab turish kerak. Bu holat (state) va unga yig'ilgan ma'lumotlar (data) — FSM'ning ikki yarmi:

Sxema
[boshlang'ich] --/login--> [telefon kutilmoqda] --raqam keldi--> [kod kutilmoqda]
                                                                      |
                                    [kabinetda] <--kod to'g'ri keldi--+

1-qadam: holatlar va storage#

Fayl tuzilishi
bot/
├── api/
│   └── auth_api.py      ← kompaniya API'si bilan gaplashish
├── states/
│   └── login.py         ← holatlar deklaratsiyasi
├── handlers/
│   ├── login.py         ← dialog qadamlari
│   └── nav_reset.py     ← "qotgan"larni qutqarish (oxirida)
├── validators.py        ← sof tekshiruv funksiyalari (testlanadi)
└── app.py               ← dispatcher + storage
Python
# bot/states/login.py
from aiogram.fsm.state import State, StatesGroup


class Login(StatesGroup):
    # har State — dialogning bitta "to'xtash bekati"
    waiting_phone = State()
    waiting_code = State()

Holatlarni Redis'da saqlash uchun aiogram'ni redis qo'shimchasi bilan o'rnatamiz — busiz RedisStorage importi No module named 'redis' bilan yiqiladi:

Terminal
uv add "aiogram[redis]"

Storage app.py da sozlanadi. Router'larni ulash qismiga 4-qadamda qaytamiz — hozircha faylning to'liq ko'rinishi:

Python
# bot/app.py
import asyncio
import os
from datetime import timedelta

from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.redis import RedisStorage

from bot.handlers import ai_calc, login, nav_reset

# Holatlar Redis'da: bot qayta ishga tushsa ham dialog joyidan davom etadi.
# MemoryStorage faqat o'yin uchun — pastdagi ogohlantirishga qarang
storage = RedisStorage.from_url(
    "redis://127.0.0.1:6379/1",
    # tugatilmagan dialog Redis'da abadiy qolmasin: bir sutkadan keyin o'chadi
    state_ttl=timedelta(hours=24),
    data_ttl=timedelta(hours=24),
)
dp = Dispatcher(storage=storage)
dp.include_router(nav_reset.router)  # 1) avval qutqaruvchi
dp.include_router(login.router)  # 2) keyin dialoglar
dp.include_router(ai_calc.router)  # 3) erkin matn (1-qism) — eng oxirida


async def main() -> None:
    await dp.start_polling(Bot(os.environ["BOT_TOKEN"]))


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

state_ttl va data_ttl — kichik, lekin muhim qatorlar. Busiz tugatilmagan dialog Redis'da abadiy qoladi: /login bosib ketib qolgan foydalanuvchi bir oydan keyin "salom" yozsa, bot hamon undan SMS kodni kutadi. TTL bilan holat bir sutkadan keyin o'zi o'chadi. Sinovda Redis'da holat kalitlari aynan 86400 soniyalik muddat bilan yozildi.

MemoryStorage bilan production'ga chiqmang

Har deploy'da bot qayta ishga tushadi — MemoryStorage'dagi barcha holatlar o'chadi. SMS kod kutayotgan yuz foydalanuvchi "javobsiz bot"ga yozib qoladi: bot ularning kodini endi hech qaysi dialogga bog'lay olmaydi — xabar e'tiborsiz qoladi yoki umuman boshqa handler'ga (masalan, 1-qismdagi erkin matnli hisobga) tushadi. Redis bilan esa restart foydalanuvchiga umuman sezilmaydi: sinovda bot boshqa jarayonda qayta ishga tushgach ham dialog kod bosqichidan davom etdi.

2-qadam: validatsiya — handler'dan tashqarida#

Har qadamda foydalanuvchi istalgan narsani yozishi mumkin. Tekshiruvlarni sof funksiya qilib ajratamiz — ular aiogram'siz testlanadi. Raqamlar uchun \d emas, [0-9] ishlatilganiga e'tibor bering: Python'da \d va isdigit() arabcha ٩٠١ kabi boshqa yozuvlardagi raqamlarni ham "raqam" deb biladi, va +998٩٠١٢٣٤٥٦٧ telefon sifatida SMS API'ga ketib qolardi:

Python
# bot/validators.py
import re

# [0-9], \d emas: \d arabcha "٩٠" kabi boshqa yozuvlardagi raqamlarni ham o'tkazadi
PHONE_RE = re.compile(r"^\+?998[0-9]{9}$")
SMS_CODE_RE = re.compile(r"^[0-9]{6}$")


def normalize_phone(raw: str) -> str | None:
    """'+998 90 123-45-67' -> '+998901234567'; yaroqsiz bo'lsa None."""
    digits = re.sub(r"[^0-9+]", "", raw)
    if not digits.startswith("+"):
        digits = "+" + digits
    return digits if PHONE_RE.match(digits) else None


def is_sms_code(raw: str) -> bool:
    return SMS_CODE_RE.match(raw.strip()) is not None

3-qadam: dialog qadamlari#

Bot kompaniya backend'i bilan auth_api orqali gaplashadi. Uning ichki tuzilishi har loyihada boshqacha, bot uchun esa uchta funksiya muhim:

Python
# bot/api/auth_api.py — interfeys (ichi kompaniya API'siga so'rov yuboradi)
async def send_sms(phone: str) -> None:
    """Shu raqamga bir martalik kod yuborilsin."""


async def verify(phone: str, code: str) -> str | None:
    """Kod to'g'ri bo'lsa kabinet tokenini, aks holda None qaytaradi."""


async def save_session(telegram_id: int, token: str) -> None:
    """Tokenni Telegram foydalanuvchisiga bog'lab saqlaydi."""
Python
# bot/handlers/login.py
from aiogram import F, Router, types
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext

from bot.api import auth_api  # kompaniya API'si (SMS yuboradi/tekshiradi)
from bot.states.login import Login
from bot.validators import is_sms_code, normalize_phone

router = Router()

MAX_CODE_ATTEMPTS = 5

# buyruq emas, oddiy matn: /calc, /help kabi buyruqlar dialog ichida ham
# o'z handler'iga yetib borsin, "kod noto'g'ri" javobiga tushib qolmasin
USER_TEXT = F.text & ~F.text.startswith("/")


@router.message(Command("login"))
async def login_start(message: types.Message, state: FSMContext):
    await state.clear()  # oldingi urinishdan qolgan data (hisoblagich ham) tozalansin
    await state.set_state(Login.waiting_phone)  # bekatga keldik
    await message.answer("Telefon raqamingizni yuboring: +998XXXXXXXXX")


@router.message(Login.waiting_phone, USER_TEXT)
async def got_phone(message: types.Message, state: FSMContext):
    phone = normalize_phone(message.text)
    if phone is None:
        # holatni O'ZGARTIRMAYMIZ — foydalanuvchi shu bekatda qoladi
        # va qayta urinadi. Xato = dialogning oxiri emas
        await message.answer("Raqam formati noto'g'ri. Namuna: +998901234567")
        return
    await auth_api.send_sms(phone)
    # keyingi qadamga KERAK bo'ladigan ma'lumotni holat datasiga yozamiz
    await state.update_data(phone=phone, attempts=0)
    await state.set_state(Login.waiting_code)
    await message.answer("SMS'dagi 6 xonali kodni kiriting.")


@router.message(Login.waiting_code, USER_TEXT)
async def got_code(message: types.Message, state: FSMContext):
    if not is_sms_code(message.text):
        await message.answer("Kod 6 ta raqamdan iborat bo'ladi. Qayta kiriting.")
        return
    data = await state.get_data()  # avvalgi qadamda yozilgan phone va hisoblagich
    token = await auth_api.verify(data["phone"], message.text.strip())
    if token is None:
        attempts = data["attempts"] + 1
        if attempts >= MAX_CODE_ATTEMPTS:
            # 6 xonali kodni terib topishga yo'l qo'ymaymiz
            await state.clear()
            await message.answer("Urinishlar tugadi. Yangi kod olish uchun /login.")
            return
        await state.update_data(attempts=attempts)
        left = MAX_CODE_ATTEMPTS - attempts
        await message.answer(f"Kod noto'g'ri yoki eskirgan. Yana {left} ta urinish qoldi.")
        return
    # token — kabinetga kirish kaliti; uni doimiy joyga saqlaymiz:
    # state.clear() holat datasini ham o'chiradi
    await auth_api.save_session(message.from_user.id, token)
    await state.clear()  # dialog tugadi — holat tozalanadi
    await message.answer("Xush kelibsiz! Kabinet: /menu")

Uch qoida shu kodda ko'rinib turibdi:

  1. Xato holatni buzmaydi — yaroqsiz kiritishda set_state chaqirilmaydi, foydalanuvchi joyida qayta urinadi;
  2. Qadamlararo ma'lumot update_datada — global o'zgaruvchida emas (bir vaqtda yuz odam dialogda bo'ladi!);
  3. Dialog oxiri — state.clear() — tozalanmagan holat keyingi suhbatlarni "yeydi". Shuning uchun token undan oldin doimiy joyga saqlanadi: clear() holat datasini ham o'chiradi.

Yana ikki himoya kodda bor:

  • urinishlar soni cheklangan. 6 xonali kodda million variant bor, cheklov bo'lmasa uni botning o'zi orqali terib topish mumkin — sinovda maqolaning avvalgi versiyasi ketma-ket 6 ta noto'g'ri kodni hech qanday to'siqsiz qabul qildi. Endi 5-urinishdan keyin dialog yopiladi, yangi /login hisoblagichni nolga tushiradi. Bu Telegram hisobi bo'yicha cheklov; bir nechta hisobdan urinishlarga qarshi xuddi shunday cheklov backend'da, telefon raqami bo'yicha ham turishi shart;

  • USER_TEXT filtri. Dialog handler'lari faqat buyruq bo'lmagan matnni oladi. Busiz kod kutish bosqichida yozilgan /calc ham "kod noto'g'ri" javobini olardi — sinovda aynan shunday bo'ldi, holbuki bosh menyu foydalanuvchiga /calc ni taklif qiladi.

4-qadam: «qotib qolgan» foydalanuvchini qutqarish#

Eng ko'p uchraydigan FSM dardi: foydalanuvchi kod kutish bekatida turib /menu yozadi — lekin bu xabarni Login.waiting_code handler'i "kod emas" deb rad etaveradi. Foydalanuvchi nazarida bot qotib qolgan.

Humo'dagi yechim — global navigatsiya-reset: asosiy buyruqlar har qanday holatda ishlaydi va holatni tozalab yuboradi:

Python
# bot/handlers/nav_reset.py
from aiogram import Router, types
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext

router = Router()

# Bu router dispatcher'ga BIRINCHI ulanadi — FSM handler'lardan oldin ishlaydi
NAV_COMMANDS = ["start", "menu", "help", "cancel"]


@router.message(Command(*NAV_COMMANDS))
async def nav_reset(message: types.Message, state: FSMContext):
    """Global navigatsiya-reset: foydalanuvchini FSM holatidan chiqaradi."""
    if await state.get_state() is not None:
        await state.clear()
    # shu yerdan odatiy menyu/started oqimiga o'tib ketiladi
    await message.answer("Bosh menyu: /calc — hisoblash, /login — kabinet")

Router'lar app.py da ulangan tartibga e'tibor bering: avval qutqaruvchi, keyin dialoglar va eng oxirida 1-qismdagi erkin matn router'i. Sinovda to'liq app.py bilan telefon raqami va SMS kod login dialogiga tushdi, AI'ga esa faqat dialogdan tashqaridagi erkin matn yetib bordi.

Router tartibi — bu prioritet

aiogram xabarni router'larga ulangan tartibda taklif qiladi. Qutqaruvchi router oxirida tursa, /menu avval FSM handler'iga tushadi va "kod noto'g'ri" javobini oladi. Tartib — kodda ko'rinmaydigan, lekin xatti- harakatni butunlay o'zgartiradigan sozlama; izoh yozib qo'ying.

5-qadam: test — validatorlar va oqim#

Python
# tests/test_validators.py
from bot.validators import is_sms_code, normalize_phone


def test_phone_normalization():
    assert normalize_phone("+998 90 123-45-67") == "+998901234567"
    assert normalize_phone("998901234567") == "+998901234567"
    assert normalize_phone("90 123 45 67") is None  # kod yo'q
    assert normalize_phone("+7 900 000 00 00") is None  # boshqa davlat
    assert normalize_phone("+998٩٠١٢٣٤٥٦٧") is None  # arabcha raqamlar


def test_sms_code_shape():
    assert is_sms_code(" 123456 ")
    assert not is_sms_code("12345")
    assert not is_sms_code("kod123")
    assert not is_sms_code("١٢٣٤٥٦")  # arabcha raqamlar
Terminal
uv run python -m pytest tests -q

Dialog oqimining o'zini ham tekshirsa bo'ladi — aiogram holat obyektlari oddiy Python, FSMContextni test storage bilan yaratib, handler funksiyalarini to'g'ridan-to'g'ri chaqirasiz. Lekin eng katta foyda — validatorlarning sof funksiya ekani: mantiqning eng sinuvchan qismi framework'siz testlanadi.

Xulosa#

  • Bot dialogi — holatlar mashinasi: har qadam alohida State, ma'lumot update_datada, yakun state.clear();

  • Storage — Redis: restart foydalanuvchi dialogini o'ldirmasin, TTL esa tugatilmagan dialogni abadiy osiltirib qo'ymasin;

  • SMS kod urinishlari cheklanadi — botda ham, backend'da ham;

  • Yaroqsiz kiritish holatni o'zgartirmaydi — foydalanuvchi bekatida qoladi;
  • Global nav-reset router'i birinchi ulanadi — "qotgan" foydalanuvchi har doim /menu bilan qutuladi;

  • Validatsiya — sof funksiyalarda: test oson, mantiq bitta joyda.

Keyingi qismda botga jadval bo'yicha ishlaydigan vazifalarni qo'shamiz: haftalik hisobotlar, kunlik anomaliya tekshiruvi va adminlarga avtomatik ogohlantirishlar.

13 daqiqa 123 marta o‘qildi