Финальный проект: бот-помощник

Сегодня мы собираем всё, чему научились за курс, в одного живого бота — «Цыплёнка-помощника», который умеет всё сразу.
Финальный проект — это не новый код с нуля, а сборка уже знакомых кусочков (команды, кнопки, FSM, база, внешний API) в один аккуратный, работающий бот.

Зачем это нужно: момент, когда всё складывается

Помнишь, как начинался курс? «Цыплёнок-помощник» умел только одно: на команду /start отвечал «Привет!». Маленький, глупый, но твой. С тех пор он подрос: научился показывать кнопки и меню, вести диалог по шагам через FSM, запоминать пользователей в SQLite, ходить за погодой в интернет и даже работать в группах.

Но всё это время умения жили как будто по отдельным комнатам. Кнопки — в одном уроке, база — в другом, погода — в третьем. И вот настал момент, ради которого всё затевалось: открыть все двери и соединить комнаты в один дом. Это как собрать конструктор: ты долго делал отдельные детали — колёса, кабину, кузов, — а сегодня прикручиваешь их к одной раме, и из кучки деталей получается машинка, которая реально едет.

К концу урока «Цыплёнок-помощник» будет таким: человек пишет /start — бот здоровается по имени (потому что помнит его из базы), показывает меню-кнопки «Погода», «Мой профиль», «Настройки». Жмёшь «Погода» — бот спрашивает город через FSM и приносит прогноз из внешнего API. Жмёшь «Профиль» — показывает, что знает о тебе. Это уже не игрушка, а маленький, но настоящий бот, какой не стыдно показать друзьям и выложить в общий чат.

Мы будем опираться на два недавних урока: модель пользователя и хранение настроек мы разбирали в «Модель пользователя и настройки», а поход за погодой во внешний сервис — в «Бот узнаёт погоду». Сегодня мы не учим новое — мы наводим порядок и связываем.

Как устроен собранный бот: метафора дома с комнатами

Когда деталей мало, можно свалить весь код в один файл bot.py — и ничего страшного. Но у нашего цыплёнка уже десятки хэндлеров: команды, кнопки, диалоги, база. Если оставить всё в одной куче, файл превратится в простыню на тысячу строк, где невозможно ничего найти. Это как комната, куда ты свалил одежду, учебники, провода и остатки пиццы — вроде всё тут, но взять нужное нереально.

Поэтому мы делим бота на комнаты — модули. Каждая комната отвечает за своё: одна за команды, другая за погоду, третья за работу с базой. А связывает их роутер.

Router (роутер) — это объект aiogram, в который ты складываешь группу связанных по смыслу хэндлеров. Потом главный Dispatcher подключает все роутеры к себе — как прихожая, из которой двери ведут в каждую комнату.

Вспомни нашу старую метафору: бот — это официант, который бегает между тобой и кухней Telegram. Так вот, Dispatcher (dp) — это старший официант на входе. Он встречает каждое обновление (новое сообщение или нажатую кнопку) и решает, в какой зал его отправить. Роутеры — это залы: «зал команд», «зал погоды». А хэндлеры внутри роутера — это уже конкретные официанты, которые знают, что подать на тот или иной запрос. Когда приходит сообщение, старший официант не разбирается с ним сам — он по очереди обходит залы и спрашивает: «это для тебя? а для тебя?» Первый зал, у которого нашёлся подходящий хэндлер, забирает обновление и обслуживает гостя. Поэтому так важно держать каждый зал узким и аккуратным: чтобы «зал погоды» не хватал чужие заказы.

У такой схемы есть приятный бонус. Пока умения разложены по комнатам, ты можешь спокойно дорабатывать одну из них, не боясь сломать остальные. Захотел добавить боту новую способность — например, показывать новости — просто заводишь новую комнату handlers/news.py со своим роутером и подключаешь её одной строкой. Старый код при этом даже не трогаешь. Это и есть взрослый подход к проекту: код растёт не вширь в одном файле, а вглубь — новыми аккуратными модулями.

Структура проекта будет такой:

chickbot/
    bot.py            # точка входа: создаём bot и dp, подключаем роутеры
    config.py         # читаем токен из переменной окружения
    db.py             # работа с SQLite: создать таблицу, добавить/найти юзера
    handlers/
        commands.py   # /start, /help и главное меню
        weather.py    # диалог про погоду через FSM + поход в API
        profile.py    # показать профиль и настройки

Результат: в проекте теперь несколько файлов вместо одного, и в каждом — только связанные между собой хэндлеры. Найти «где у меня погода» можно за секунду: она в handlers/weather.py.

Пример 1. Главный файл bot.py — прихожая дома

Начнём с центра — файла bot.py. Его задача проста: создать объекты bot и dp (те самые, что мы используем весь курс), подключить к диспетчеру все роутеры из комнат, подготовить базу и запустить polling.

import asyncio
import logging

from aiogram import Bot, Dispatcher

from config import BOT_TOKEN
from db import init_db
from handlers import commands, weather, profile


async def main():
    logging.basicConfig(level=logging.INFO)

    # 1. Готовим базу: создаём таблицу, если её ещё нет
    init_db()

    # 2. Те самые bot и dp, что мы знаем весь курс
    bot = Bot(token=BOT_TOKEN)
    dp = Dispatcher()

    # 3. Подключаем комнаты-роутеры к старшему официанту
    dp.include_router(commands.router)
    dp.include_router(weather.router)
    dp.include_router(profile.router)

    # 4. Запускаем polling: бот сам спрашивает Telegram о новостях
    await dp.start_polling(bot)


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

Результат: в чате бот ничего не отвечает сам по себе, но при запуске python bot.py в консоли появляется лог «Start polling», и с этого момента цыплёнок слушает Telegram и готов реагировать на любую команду или кнопку из подключённых роутеров.

Разберём по шагам. init_db() мы зовём первым делом — нельзя записывать пользователей в таблицу, которой ещё нет. Дальше создаём bot с токеном (его читаем из config.py, чтобы секрет не валялся в коде) и пустой Dispatcher. Метод dp.include_router(...) — это и есть «открыть дверь в комнату»: после него все хэндлеры из этого роутера начинают работать. Порядок подключения важен: aiogram проверяет роутеры сверху вниз и отдаёт обновление первому подходящему хэндлеру. И наконец start_polling запускает вечный цикл «спроси Telegram → обработай → повтори».

Маленькая деталь: config.py и секрет токена

Токен — это пароль от твоего бота, его нельзя класть прямо в код, который ты потом выложишь на GitHub. Поэтому держим его в переменной окружения и читаем в отдельном файле:

import os

token = os.environ.get("BOT_TOKEN")
if token is None:
    print("Ошибка: переменная BOT_TOKEN не задана!")
else:
    # показываем только начало — секрет не светим целиком
    print("Токен прочитан, начинается с:", token[:6])

Вывод:

Ошибка: переменная BOT_TOKEN не задана!

В этом маленьком сниппете видно главное правило: код сам не хранит секрет, он лишь берёт его из окружения функцией os.environ.get. Если переменную забыли задать — лучше честно сказать об этом, чем падать с непонятной ошибкой. В реальном config.py вместо print мы бы подняли исключение, но идея та же.

Пример 2. Комната команд: /start, который помнит тебя

Теперь самая частая дверь — файл handlers/commands.py. Здесь живёт /start. И он не просто здоровается: он заглядывает в базу, узнаёт, видел ли тебя раньше, и показывает главное меню кнопками. Так соединяются сразу три умения: команда, база и reply-клавиатура.

from aiogram import Router
from aiogram.filters import CommandStart
from aiogram.types import Message, ReplyKeyboardMarkup, KeyboardButton

from db import get_user, add_user

router = Router()

# Главное меню бота — те же кнопки на всех экранах
main_menu = ReplyKeyboardMarkup(
    keyboard=[
        [KeyboardButton(text="\U0001F324 Погода"), KeyboardButton(text="\U0001F423 Мой профиль")],
        [KeyboardButton(text="\u2699 Настройки")],
    ],
    resize_keyboard=True,
)


@router.message(CommandStart())
async def cmd_start(message: Message):
    user = get_user(message.from_user.id)

    if user is None:
        # Видим человека впервые — запоминаем его
        add_user(message.from_user.id, message.from_user.full_name)
        text = f"Привет, {message.from_user.full_name}! Я цыплёнок-помощник, будем знакомы."
    else:
        # Уже знакомы — здороваемся как со старым другом
        text = f"С возвращением, {message.from_user.full_name}! Чем помочь?"

    await message.answer(text, reply_markup=main_menu)

Результат: в чате бот ответит так — если ты пишешь /start впервые, он напишет «Привет, Имя! Я цыплёнок-помощник, будем знакомы» и покажет под полем ввода три кнопки: «Погода», «Мой профиль», «Настройки». Если ты уже запускал бота раньше — поздоровается «С возвращением, Имя!» и покажет то же меню.

Разбор. Фильтр CommandStart() ловит именно /start. Дальше мы зовём get_user из нашего модуля db.py — функцию, которая ищет человека в SQLite по его id. Если вернулся None, значит, видим его впервые: добавляем через add_user и здороваемся как с новичком. Если запись нашлась — приветствуем как знакомого. В обоих случаях прикладываем main_menu — это reply-клавиатура, кнопки которой при нажатии шлют боту обычный текст вроде «Погода». Этот текст потом поймают другие хэндлеры.

Как сами функции базы выглядят внутри

В db.py прячется обычный SQL к SQLite. Таблицу пользователей создаём так:

CREATE TABLE IF NOT EXISTS users (
    user_id    INTEGER PRIMARY KEY,
    full_name  TEXT,
    city       TEXT
);

Колонка user_id — первичный ключ, по нему мы и находим человека. full_name — имя для приветствия, city — город, который пригодится для погоды, чтобы не спрашивать его каждый раз. Конструкция IF NOT EXISTS означает «создай таблицу, только если её ещё нет» — поэтому init_db() можно спокойно звать при каждом запуске.

Пример 3. Комната погоды: кнопка → FSM → внешний API

Самая интересная дверь — handlers/weather.py. Здесь сходится сразу всё: нажатие кнопки запускает диалог по шагам (FSM), бот спрашивает город, а потом идёт во внешний сервис за прогнозом. Напомню метафору: FSM — это анкета, где поля заполняют по очереди. Сейчас в анкете одно поле — «город».

from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import Message

from db import set_city
from services.weather import get_weather  # наша функция из урока про погоду

router = Router()


class WeatherForm(StatesGroup):
    waiting_city = State()  # ждём, пока человек напишет город


@router.message(F.text == "\U0001F324 Погода")
async def ask_city(message: Message, state: FSMContext):
    await message.answer("В каком городе смотрим погоду? Напиши название.")
    await state.set_state(WeatherForm.waiting_city)


@router.message(WeatherForm.waiting_city)
async def show_weather(message: Message, state: FSMContext):
    city = message.text.strip()
    set_city(message.from_user.id, city)  # запомним город в базе

    forecast = await get_weather(city)    # идём во внешний API
    await message.answer(forecast)

    await state.clear()  # анкета заполнена — выходим из диалога

Результат: в чате бот ответит так — после нажатия кнопки «Погода» он спросит «В каком городе смотрим погоду?». Ты пишешь, например, «Казань» — и бот присылает что-то вроде «В городе Казань сейчас +18, облачно. Зонт не нужен!». Заодно он молча сохраняет этот город в базу, чтобы в будущем можно было предлагать его по умолчанию.

Разбор по шагам. Класс WeatherForm с одним State — это и есть наша анкета: пока бот в состоянии waiting_city, он понимает, что ждёт именно название города. Первый хэндлер срабатывает на текст кнопки «Погода» (помнишь, reply-кнопка шлёт обычный текст), задаёт вопрос и переводит человека в состояние ожидания через state.set_state. Второй хэндлер ловит любое сообщение, пока мы в этом состоянии: берёт текст как город, сохраняет его функцией set_city, зовёт get_weather (ту самую из урока про погоду) и шлёт ответ. В конце обязательный state.clear() — он закрывает анкету, иначе бот так и будет принимать любой текст за город.

Почему важен порядок: общие и FSM-хэндлеры

Тут есть тонкость, на которой спотыкаются почти все. Хэндлер show_weather ловит любой текст, пока активно состояние waiting_city. Если бы мы по ошибке поставили его без привязки к состоянию, он перехватывал бы вообще все сообщения в боте — и кнопки «Профиль», «Настройки» перестали бы работать. Поэтому хэндлеры с состоянием всегда узкие: они срабатывают только внутри конкретного шага диалога. Это и есть та самая аккуратность сборки, ради которой мы разнесли код по комнатам.

Заметь ещё одну приятную вещь: благодаря FSM боту не нужно держать в голове кучу переменных вида «а этот человек сейчас вводит город или просто болтает?». За него это помнит хранилище состояний — то самое Storage, о котором мы говорили в уроках про FSM. Каждый пользователь как будто заполняет свою личную анкету, и бот не путает, кто на каком шаге находится, даже если десять человек одновременно жмут «Погода». Это и есть сила машины состояний: она превращает беспорядочный поток сообщений в понятный пошаговый диалог.

Частые ошибки и подводные камни

  • Забыл подключить роутер в bot.py. Самая частая беда сборки: написал отличную комнату profile.py, а бот её «не видит». Причина — нет строки dp.include_router(profile.router). Без подключения роутер мёртв: его хэндлеры не сработают никогда. Подключил новую комнату — проверь, что добавил её в диспетчер.
  • Перепутал импорты по кругу. Если commands.py импортирует из weather.py, а weather.py — обратно из commands.py, Python ругнётся на циклический импорт. Лекарство: общие вещи (например функции базы) держи в нейтральном модуле db.py, который импортируют все, а сам он — никого из хэндлеров.
  • Не вызвал state.clear() в конце диалога. Тогда бот навсегда остаётся в состоянии «жду город»: ты жмёшь «Профиль», а он упорно считает это названием города. Всегда закрывай анкету после последнего шага.
  • Зовёшь init_db() внутри хэндлера, а не при старте. Тогда таблица пересоздаётся при каждом сообщении (в лучшем случае впустую, в худшем — стирает данные). База готовится один раз в main() до запуска polling.
  • Токен прямо в коде вместо переменной окружения. Выложил проект на GitHub с реальным токеном — и любой может управлять твоим ботом. Токен читаем только из окружения через config.py, а сам ключ в репозиторий не кладём.

Мини-практика: достраиваем профиль и настройки

Комнаты «Погода» и «Команды» у тебя уже собраны. Осталась дверь «Профиль» — доделай её сам, по тому же образцу. Задание:

  1. Создай файл handlers/profile.py со своим router = Router() и не забудь подключить его в bot.py через dp.include_router.
  2. Сделай хэндлер на текст кнопки «\U0001F423 Мой профиль»: достань пользователя из базы функцией get_user и покажи, что бот о нём знает — имя и сохранённый город (или «город пока не задан»).
  3. Сделай хэндлер на кнопку «\u2699 Настройки»: предложи через FSM сменить город по умолчанию — спроси новый город и сохрани его функцией set_city, как в комнате погоды.
  4. Проверь, что после смены настроек диалог закрывается через state.clear(), а главное меню остаётся на месте.

Подсказка: структура хэндлера профиля почти один в один повторяет cmd_start — берёшь юзера из базы и формируешь текст. А диалог настроек — это копия диалога погоды, только вместо запроса прогноза ты просто подтверждаешь: «Запомнил, теперь твой город — …». Ты уже всё это писал, осталось переставить кубики.

Итоги

Поздравляю — ты собрал настоящего бота! Сегодня «Цыплёнок-помощник» из набора разрозненных умений превратился в единый проект. Ты научился: разбивать бота на модули-комнаты и связывать их через Router и dp.include_router; делать аккуратную точку входа bot.py, где готовится база, создаются bot и dp и запускается polling; соединять в одном сценарии команду, базу, reply-кнопки, FSM-диалог и поход во внешний API; обходить классические грабли сборки — забытый роутер, циклические импорты, незакрытый state и токен в коде.

Это финал нашего пути по умениям, но не финал бота. Дальше «Цыплёнка-помощника» ждёт большой мир: его нужно поднять не на твоём ноутбуке, а на настоящем сервере, чтобы он работал круглосуточно, даже когда компьютер выключен. В следующем уроке мы научимся выкладывать готового бота в облако — и тогда им смогут пользоваться твои друзья в любое время суток. Ты прошёл весь путь от «Привет!» до рабочего проекта — осталось выпустить его в свет.

Проверьте себя
1. Зачем в финальном проекте бота разбивают на отдельные модули-комнаты (commands.py, weather.py, profile.py)?
AБез этого aiogram вообще не запустится
BЧтобы код не превращался в одну простыню и нужный хэндлер легко находился
CЧтобы бот работал быстрее в несколько раз
DЭто требование Telegram для всех ботов
2. Что делает строка dp.include_router(weather.router) в bot.py?
AСоздаёт новый объект бота
BУдаляет старые хэндлеры
CПодключает группу хэндлеров из роутера к диспетчеру, чтобы они начали работать
DОтправляет сообщение пользователю
3. Почему функцию init_db() вызывают один раз в main() при запуске, а не внутри хэндлера?
AВнутри хэндлера она пересоздавалась бы при каждом сообщении и могла бы навредить данным
BВ хэндлере её просто нельзя вызвать синтаксически
Cinit_db работает только до создания бота
DTelegram запрещает обращаться к базе из хэндлеров
4. Что произойдёт, если в диалоге про погоду забыть вызвать state.clear() в конце?
AБот упадёт с ошибкой
BГород не сохранится в базу
CБот навсегда останется в состоянии «жду город» и будет принимать за город даже нажатия других кнопок
DTelegram заблокирует бота
5. Почему токен бота читают из переменной окружения через config.py, а не пишут прямо в коде?
AТак код работает быстрее
BТокен в коде попадёт, например, на GitHub, и любой сможет управлять твоим ботом
Caiogram не принимает токен в виде строки
DПеременные окружения шифруют токен автоматически
6. Как в собранном боте reply-кнопка «Погода» запускает обработку?
AКнопка шлёт скрытый callback, который ловит callback-хэндлер
BКнопка отправляет в чат обычный текст «Погода», который ловит соответствующий message-хэндлер
CКнопка напрямую вызывает внешний API погоды
DКнопка меняет состояние FSM без участия хэндлеров