Asosiy kontentga o‘tish

Maqolalar

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

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

#python #aiogram #telegram-bot #fsm #redis

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:

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

1-qadam: holatlar va storage#

bot/
├── 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
# 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()
# bot/app.py
from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.redis import RedisStorage

# 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")
dp = Dispatcher(storage=storage)

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 xabarini hech qaysi holatga tegishli emas deb e'tiborsiz qoldiradi. Redis bilan esa restart foydalanuvchiga umuman sezilmaydi.

2-qadam: validatsiya — handler'dan tashqarida#

Har qadamda foydalanuvchi istalgan narsani yozishi mumkin. Tekshiruvlarni sof funksiya qilib ajratamiz — ular aiogram'siz testlanadi:

# bot/validators.py
import re

PHONE_RE = re.compile(r"^\+?998\d{9}$")


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


def is_sms_code(raw: str) -> bool:
    return raw.strip().isdigit() and len(raw.strip()) == 6

3-qadam: dialog qadamlari#

# 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()


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


@router.message(Login.waiting_phone, F.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)
    await state.set_state(Login.waiting_code)
    await message.answer("SMS'dagi 6 xonali kodni kiriting.")


@router.message(Login.waiting_code, F.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
    token = await auth_api.verify(data["phone"], message.text.strip())
    if token is None:
        await message.answer("Kod noto'g'ri yoki eskirgan. Qayta kiriting yoki /login.")
        return
    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".

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:

# 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")
# bot/app.py (davomi)
from bot.handlers import login, nav_reset

dp.include_router(nav_reset.router)  # 1) avval qutqaruvchi
dp.include_router(login.router)  # 2) keyin dialoglar

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#

# 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


def test_sms_code_shape():
    assert is_sms_code(" 123456 ")
    assert not is_sms_code("12345")
    assert not is_sms_code("kod123")

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;
  • 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.