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.
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/
├── 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
# 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:
uv add "aiogram[redis]"
Storage app.py da sozlanadi. Router'larni ulash qismiga 4-qadamda
qaytamiz — hozircha faylning to'liq ko'rinishi:
# 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:
# 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:
# 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."""
# 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:
- Xato holatni buzmaydi — yaroqsiz kiritishda
set_statechaqirilmaydi, foydalanuvchi joyida qayta urinadi; - Qadamlararo ma'lumot
update_datada — global o'zgaruvchida emas (bir vaqtda yuz odam dialogda bo'ladi!); - 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
/loginhisoblagichni 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_TEXTfiltri. Dialog handler'lari faqat buyruq bo'lmagan matnni oladi. Busiz kod kutish bosqichida yozilgan/calcham "kod noto'g'ri" javobini olardi — sinovda aynan shunday bo'ldi, holbuki bosh menyu foydalanuvchiga/calcni 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:
# 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#
# 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
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'lumotupdate_datada, yakunstate.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
/menubilan 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.