Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 10 из 10 Продвинутый 45 мин 150 XP

Проект: телеграм-бот трекер расходов на aiogram с базой данных

Сквозной проект курса: бот-трекер расходов с inline-кнопками, FSM-диалогом, SQLite-хранилищем и отчётом за месяц. Рабочая версия в песочнице, полный код на aiogram и чек-лист запуска.

Редакция Питоники

Девять уроков позади: хендлеры и фильтры, клавиатуры, машина состояний, SQLite, асинхронность, middleware и ошибки. По отдельности всё работает — теперь соберём это в законченную вещь, которой можно пользоваться каждый день: телеграм-бота-трекер расходов. Написал /add, ткнул в категорию — вечером /report показывает, куда утекает зарплата.

Формат урока такой: сначала техзадание, потом каждый механизм проекта запускается прямо на странице — песочница умеет настоящую sqlite3, и главный интерактив сегодня покажет живой диалог с ботом. Затем — полный код на aiogram 3 одним листингом, структура файлов и чек-лист запуска на своём компьютере с токеном от BotFather.

Что будет уметь бот: техническое задание

Начнём как в настоящем проекте — с договора о том, что считаем готовым результатом. Шесть пунктов, ничего лишнего:

  1. Команда /start — приветствие и подсказка по командам.
  2. Команда /add запускает диалог: бот спрашивает сумму, потом категорию из inline-кнопок.
  3. Сумма принимается с запятой или точкой («149,90» тоже деньги), мусор вроде «двести» вежливо отклоняется.
  4. Каждая трата сохраняется в SQLite с chat_id — ботом можно пользоваться и в личке, и в группах.
  5. Команда /report — сводка за месяц: сумма по каждой категории, от большей к меньшей.
  6. Бот не падает на кривом вводе: за это отвечает try/except из урока 9.

Как устроена база данных: одна таблица расходов

Для трекера не нужна модель данных на десять таблиц: трата — это сумма, категория, кто и когда. Всё хранится в одной таблице, и chat_id в ней — главный ключ многопользовательности: именно по нему отчёт отделяет ваши траты от чужих, если бот живёт в групповом чате.

ПолеТипЗачем
idINTEGER PRIMARY KEYномер записи, нужен SQLite для порядка
chat_idINTEGERиз какого чата пришла трата — фильтр отчёта
amountREALсумма в рублях, уже проверенная парсером
categoryTEXTЕда, Транспорт, Развлечения, Другое
createdTEXTдата в формате YYYY-MM-DD — по ней ищем месяц
SQL-схема таблицы
CREATE TABLE IF NOT EXISTS expenses (
    id       INTEGER PRIMARY KEY AUTOINCREMENT,
    chat_id  INTEGER,   -- чат, из которого пришла трата
    amount   REAL,      -- сумма в рублях
    category TEXT,      -- Еда, Транспорт, Развлечения, Другое
    created  TEXT       -- дата траты в формате YYYY-MM-DD
);
Это SQL, а не Python: в боте он живёт строкой внутри db.execute. IF NOT EXISTS позволяет вызывать схему при каждом старте бота - таблица создаётся один раз.

Как принять сумму и не упасть на слове «двести»?

Первое место, где проект ломается вживую, — ввод суммы. Люди пишут «149,90» с запятой, «250.» с точкой на конце и «двести» словами. Парсер суммы — маленькая функция, но именно она делает разницу между демо и программой:

парсер суммы: запятая, мусор, отрицательные
def parse_amount(text):
    try:
        amount = float(text.replace(",", "."))
    except ValueError:
        return None
    if amount <= 0 or amount > 10_000_000:
        return None
    return amount

for probe in ["250", "149,90", "двести", "-5"]:
    print(probe, "->", parse_amount(probe))
Вывод
250 -> 250.0
149,90 -> 149.9
двести -> None
-5 -> None

Три решения здесь сознательные. Запятая заменяется на точку до float — иначе «149,90» упадёт с ValueError. Мусор и отрицательные числа — это None, то есть «не трата», а не исключение: хендлер проверит на None и попросит повторить ввод, как учил урок про обработку ошибок. А верхний предел отсекает и опечатки в десять лишних нулей, и подколки вроде «-100» в попытке сделать бюджет больше.

Как собрать отчёт за месяц: GROUP BY по категории

Отчёт — это один SQL-запрос, и это лучший аргумент за базу данных вместо словаря в памяти. Даты мы храним ISO-строкой YYYY-MM-DD, а она сортируется и фильтруется как текст: сентябрь — это префикс «2026-09». Сгруппируем траты по категории и посчитаем суммы:

отчёт за месяц: WHERE + GROUP BY + ORDER BY
import sqlite3

db = sqlite3.connect(":memory:")
db.execute(
    "CREATE TABLE expenses ("
    "chat_id INTEGER, amount REAL, category TEXT, created TEXT)"
)
rows = [
    (101, 250, "Еда", "2026-09-02"),
    (101, 149.9, "Транспорт", "2026-09-05"),
    (101, 340, "Еда", "2026-09-12"),
    (202, 999, "Развлечения", "2026-08-30"),  # чужой чат и прошлый месяц
]
db.executemany("INSERT INTO expenses VALUES (?, ?, ?, ?)", rows)

report = db.execute(
    "SELECT category, SUM(amount), COUNT(*)"
    " FROM expenses"
    " WHERE chat_id = ? AND created LIKE '2026-09%'"
    " GROUP BY category"
    " ORDER BY SUM(amount) DESC",
    (101,),
).fetchall()

for category, total, count in report:
    print(category, total, count, sep=" | ")
Вывод
Еда | 590.0 | 2
Транспорт | 149.9 | 1

Запрос читается как фраза: выбери категорию, сумму и количество трат этого чата за сентябрь (LIKE '2026-09%' — любая дата, начинающаяся с префикса), сверни в группы по категории и отсортируй по убыванию суммы. Две строки из таблицы не попали в отчёт — трата чата 202 и августовская запись; GROUP BY честно отработал по фильтру WHERE. Месяц в запросе подставляется параметром — из него делается префикс, поэтому /report всегда считает нужный месяц.

параметризованные запросы: добавить трату, узнать итог
import sqlite3

db = sqlite3.connect(":memory:")
db.execute(
    "CREATE TABLE expenses ("
    "chat_id INTEGER, amount REAL, category TEXT, created TEXT)"
)
db.executemany(
    "INSERT INTO expenses VALUES (?, ?, ?, ?)",
    [
        (101, 250, "Еда", "2026-09-02"),
        (101, 149.9, "Транспорт", "2026-09-05"),
        (202, 70, "Еда", "2026-09-08"),
    ],
)

def add_expense(chat_id, amount, category, created):
    # параметры (?, ?) защищают и от SQL-инъекций, и от проблем с кавычками
    db.execute(
        "INSERT INTO expenses (chat_id, amount, category, created)"
        " VALUES (?, ?, ?, ?)",
        (chat_id, amount, category, created),
    )

def month_total(chat_id, month_prefix):
    row = db.execute(
        "SELECT SUM(amount) FROM expenses WHERE chat_id = ? AND created LIKE ?",
        (chat_id, month_prefix + "%"),
    ).fetchone()
    return row[0] or 0.0

add_expense(101, 60.5, "Еда", "2026-09-20")
print(f"Итог чата 101 за сентябрь: {month_total(101, '2026-09')}")
print(f"Итог чата 202 за сентябрь: {month_total(202, '2026-09')}")
print(f"Итог чата 101 за август: {month_total(101, '2026-08')}")
Вывод
Итог чата 101 за сентябрь: 460.4
Итог чата 202 за сентябрь: 70.0
Итог чата 101 за август: 0.0

Заметьте row[0] or 0.0: SUM по пустой выборке возвращает None — у чата 101 в августе трат нет, и без этой подстраховки ответом была бы ошибка при + 50. Параметры ? — из урока про SQLite: никогда не собирайте SQL через f-строки, значения всегда через заполнители.

Как вести диалог «сумма → категория»: FSM по chat_id

Теперь диалог. Боту нужно помнить два шага: на каком вопросе мы остановились и что пользователь уже ответил. В aiogram это делает машина состояний со StatesGroup и MemoryStorage — а механика у неё простейшая: словарь «chat_id → состояние» плюс словарь черновика. Соберём бота целиком на чистом Python и прогоним через него настоящий диалог — включая ошибки пользователя:

трекер расходов целиком: база + FSM + диалог
import sqlite3

# --- база данных: та же таблица, что будет в настоящем боте ---
db = sqlite3.connect(":memory:")
db.execute(
    "CREATE TABLE expenses ("
    "chat_id INTEGER, amount REAL, category TEXT, created TEXT)"
)

CATEGORIES = ["Еда", "Транспорт", "Развлечения"]

# --- FSM: состояния и черновик диалога по chat_id ---
states = {}  # chat_id -> шаг диалога
drafts = {}  # chat_id -> сумма, ждущая категории (в aiogram это FSM storage)

def handle(chat_id, text):
    # Одна функция вместо трёх @router.message-хендлеров:
    # ветвление по состоянию - это и есть машина состояний
    if text == "/add":
        states[chat_id] = "amount"
        return "Сколько потратили? Пришлите сумму числом."

    if states.get(chat_id) == "amount":
        try:
            drafts[chat_id] = float(text.replace(",", "."))
        except ValueError:
            return "Это не число. Например: 250"
        states[chat_id] = "category"
        return "Куда записать? " + ", ".join(CATEGORIES)

    if states.get(chat_id) == "category":
        if text not in CATEGORIES:
            return "Нет такой категории: " + ", ".join(CATEGORIES)
        db.execute(
            "INSERT INTO expenses (chat_id, amount, category, created)"
            " VALUES (?, ?, ?, ?)",
            (chat_id, drafts.pop(chat_id), text, "2026-09-15"),
        )
        states.pop(chat_id)
        return "Записал! Жмите /add для следующей траты."

    return "Не понял. Нажмите /add, чтобы записать расход."

# --- симуляция диалога: сообщения пользователя и ответы бота ---
chat = 101
for msg in ["/add", "двести", "250", "Комиксы", "Еда", "/add", "149,90", "Транспорт"]:
    print("Пользователь:", msg)
    print("Бот:", handle(chat, msg))

# --- отчёт за месяц: GROUP BY по категории ---
print("Отчёт за сентябрь:")
report = db.execute(
    "SELECT category, SUM(amount) FROM expenses"
    " WHERE chat_id = ? AND created LIKE '2026-09%'"
    " GROUP BY category ORDER BY SUM(amount) DESC",
    (chat,),
).fetchall()
for category, total in report:
    print(" ", category, "-", total, "руб.")
Вывод
Пользователь: /add
Бот: Сколько потратили? Пришлите сумму числом.
Пользователь: двести
Бот: Это не число. Например: 250
Пользователь: 250
Бот: Куда записать? Еда, Транспорт, Развлечения
Пользователь: Комиксы
Бот: Нет такой категории: Еда, Транспорт, Развлечения
Пользователь: Еда
Бот: Записал! Жмите /add для следующей траты.
Пользователь: /add
Бот: Сколько потратили? Пришлите сумму числом.
Пользователь: 149,90
Бот: Куда записать? Еда, Транспорт, Развлечения
Пользователь: Транспорт
Бот: Записал! Жмите /add для следующей траты.
Отчёт за сентябрь:
  Еда - 250.0 руб.
  Транспорт - 149.9 руб.

Прочитайте вывод как сценарий, и вы увидите всю ценность состояний. «двести» не сломало диалог: бот остался на шаге суммы и попросил число ещё раз. «Комиксы» не записались: категория проверяется по списку, черновик 250 пережил отказ и дождался «Еда». А в настоящем боте список «Еда, Транспорт, Развлечения» — это inline-клавиатура из урока 3: пользователь не печатает категорию, а нажимает кнопку, и проверка «а есть ли такая категория» исчезает за ненадобностью.

Полный код бота на aiogram 3: один файл

Соберём всё вместе. Этот файл — рабочий бот: скопируйте, поставьте aiogram, подставьте токен — и пользуйтесь. Каждый блок вы уже видели по отдельности в песочнице выше или в прошлых уроках.

bot.py — трекер расходов на aiogram 3
import asyncio
import logging
import os
import sqlite3

from aiogram import Bot, Dispatcher, F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import (
    CallbackQuery,
    InlineKeyboardButton,
    InlineKeyboardMarkup,
    Message,
)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
router = Router()

DB_PATH = "expenses.db"
CATEGORIES = ["Еда", "Транспорт", "Развлечения", "Другое"]


def db_init():
    with sqlite3.connect(DB_PATH) as db:
        db.execute(
            "CREATE TABLE IF NOT EXISTS expenses ("
            "id INTEGER PRIMARY KEY AUTOINCREMENT,"
            "chat_id INTEGER, amount REAL, category TEXT, created TEXT)"
        )


def add_expense(chat_id: int, amount: float, category: str):
    with sqlite3.connect(DB_PATH) as db:
        db.execute(
            "INSERT INTO expenses (chat_id, amount, category, created)"
            " VALUES (?, ?, ?, date('now'))",
            (chat_id, amount, category),
        )


def month_report(chat_id: int):
    with sqlite3.connect(DB_PATH) as db:
        return db.execute(
            "SELECT category, SUM(amount) FROM expenses"
            " WHERE chat_id = ?"
            " AND created LIKE strftime('%Y-%m', 'now') || '%'"
            " GROUP BY category ORDER BY SUM(amount) DESC",
            (chat_id,),
        ).fetchall()


class AddStates(StatesGroup):
    amount = State()
    category = State()


def categories_kb():
    rows = [
        [InlineKeyboardButton(text=c, callback_data="cat:" + c)]
        for c in CATEGORIES
    ]
    return InlineKeyboardMarkup(inline_keyboard=rows)


@router.message(Command("start"))
async def cmd_start(message: Message, state: FSMContext):
    await state.clear()  # выходим из любого зависшего диалога
    await message.answer("Привет! Жми /add - запишем трату, /report - отчёт.")


@router.message(Command("add"))
async def cmd_add(message: Message, state: FSMContext):
    await state.set_state(AddStates.amount)
    await message.answer("Сколько потратили? Пришли сумму числом.")


@router.message(AddStates.amount)
async def got_amount(message: Message, state: FSMContext):
    try:
        amount = float(message.text.replace(",", "."))
    except ValueError:
        await message.answer("Это не число. Попробуй ещё раз, например: 250")
        return
    await state.update_data(amount=amount)
    await state.set_state(AddStates.category)
    await message.answer("Куда записать?", reply_markup=categories_kb())


@router.callback_query(AddStates.category, F.data.startswith("cat:"))
async def got_category(call: CallbackQuery, state: FSMContext):
    category = call.data.split(":", 1)[1]
    data = await state.get_data()
    add_expense(call.message.chat.id, data["amount"], category)
    await state.clear()
    await call.message.edit_text("Записал: " + category)


@router.message(Command("report"))
async def cmd_report(message: Message):
    rows = month_report(message.chat.id)
    if not rows:
        await message.answer("В этом месяце трат нет.")
        return
    lines = [f"{c}: {t:.2f} руб." for c, t in rows]
    await message.answer("Отчёт за месяц:" + "\n" + "\n".join(lines))


async def main():
    db_init()
    bot = Bot(token=os.environ["BOT_TOKEN"])
    dp = Dispatcher()
    dp.include_router(router)
    await bot.delete_webhook(drop_pending_updates=True)
    await dp.start_polling(bot)


if __name__ == "__main__":
    asyncio.run(main())
Настоящий aiogram: сеть из песочницы недоступна, поэтому блок не запускается - это листинг для вашего компьютера. Сверьтесь с тренажёром выше: словари states и drafts стали FSMContext, ветвление по состоянию - хендлерами с фильтрами AddStates.amount, а строка списка категорий - inline-кнопками.

Пройдитесь по листингу как по карте курса. logging.basicConfig и try/except ValueError — урок 9. Словарь callback_data «cat:Еда» с разбором через split — урок про кнопки. AddStates, update_data, get_data — машина состояний из урока 4. date('now') вместо фиксированной даты из тренажёра — SQLite сам проставит день траты, а strftime('%Y-%m', 'now') соберёт префикс текущего месяца для отчёта. Токен читается из переменной окружения — и это не перестраховка, о которой ниже.

Структура проекта: когда один файл — это нормально?

Учебный проект честно живёт в одном bot.py — примерно 130 строк, и это нормально: лучше один понятный файл, чем пять пустых. Но бот растёт, и первый признак, что пора делиться, — когда листаешь bot.py в поисках «где там был отчёт». Раскладка на будущее простая: каждый слой переезжает в свой модуль, а bot.py остаётся точкой входа.

структура папки бота: сейчас и после разрастания
expense-bot/
|-- bot.py           # сейчас: весь проект; потом - только Bot, Dispatcher, polling
|-- handlers.py      # хендлеры: /start, /add, /report, callback (переехают из bot.py)
|-- keyboards.py     # inline-клавиатура категорий
|-- db.py            # sqlite3: db_init, add_expense, month_report
|-- requirements.txt # aiogram
+-- README.md        # как запустить: токен, pip install, python bot.py
Дерево каталогов для ориентира: правый столбец комментариев показывает, что куда переезжает, когда проект вырастет. Четыре файла - тот же расклад, что у любого серверного проекта на Python.

Как запустить бота на своём компьютере: чек-лист

Песочница заканчивается здесь: чтобы бот ожил, нужен Python, сеть и токен. Порядок ровно такой:

  1. Поставьте Python 3.10 или новее (python.org, при установке отметьте «Add to PATH»).
  2. Создайте папку проекта и виртуальное окружение: python -m venv venv и активируйте его.
  3. Установите aiogram: pip install aiogram.
  4. В Telegram откройте @BotFather, отправьте /newbot, придумайте имя — получите токен вида 123456789:AA....
  5. Сохраните токен в переменную окружения BOT_TOKEN (не в код!).
  6. Положите рядом bot.py из листинга и запустите: python bot.py.
  7. Найдите своего бота в Telegram по имени, нажмите Start и отправьте /add.
терминал: от нуля до запущенного бота
python -m venv venv
venv\Scripts\activate         # Windows (macOS/Linux: source venv/bin/activate)
pip install aiogram
set BOT_TOKEN=123456789:AA...  # Windows (macOS/Linux: export BOT_TOKEN=...)
python bot.py
Команды терминала, а не код Python. В логе появится строка с INFO от aiogram - значит, polling работает; остановите бота Ctrl+C.

Итоги курса: что вы теперь умеете

Откатитесь к уроку 1: там бот умел отвечать эхом на десяти строках. Сегодня у вас бот, который ведёт многошаговый диалог с кнопками, хранит данные в базе, отличает ваши траты от чужих, считает отчёты SQL-запросом, защищается от флуда middleware и переживает ошибки перезапуском. Это не «Hello, world» — это законченная программа с архитектурой, и её можно показывать в портфолио.

Куда расти дальше. Внутри aiogram: вынесите функции базы в отдельный db.py, добавьте /cancel, категории настроечные для каждого чата, а на замену MemoryStorage — RedisStorage, чтобы диалоги переживали перезапуск. За пределами: тот же приём «проект с базой данных и состояниями» переносится на веб — посмотрите, как выглядит финальный проект блога на Flask, и вы узнаете знакомые идеи в другом жанре. Проект хорош, когда доведён до конца: работающий бот с одной таблицей лучше недописанного монстра с пятью. Доведите — и пользуйтесь.

Свой бот — это первая программа, которой пользуются другие люди. С этого момента код пишется не для себя.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE t (category TEXT, amount REAL)")
db.executemany(
    "INSERT INTO t VALUES (?, ?)",
    [("Еда", 100), ("Еда", 50), ("Транспорт", 70)],
)
rows = db.execute(
    "SELECT category, SUM(amount) FROM t"
    " GROUP BY category ORDER BY SUM(amount) DESC"
).fetchall()
print(rows)
states = {}

def handle(text):
    if states.get(1) == "category":
        states.pop(1)
        return "Записал"
    if states.get(1) == "amount":
        states[1] = "category"
        return "Категория?"
    states[1] = "amount"
    return "Сумма?"

print(handle("100"))
print(handle("Еда"))
print(handle("Еда"))
import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE e (amount REAL)")
db.execute("INSERT INTO e VALUES (250)")
row = db.execute("SELECT SUM(amount) FROM e").fetchone()
print(row, row[0] + 50)
Проверь себя
0 / 5

1. Почему в таблице расходов chat_id, а не имя пользователя?

2. Почему значения вставляются через заполнители (?, ?, ?, ?), а не через f-строку в SQL?

3. Что делает строка GROUP BY category в запросе отчёта?

4. Пользователь на шаге суммы прислал «двести». Что делает правильный хендлер?

5. Что произойдёт с незаконченными FSM-диалогами, если перезапустить бота с MemoryStorage?

Карточки терминов
Запомнено: 0 / 6
Практика

Добавьте трекеру сводку по одной категории: посчитайте сумму и число трат категории «Еда» для чата 101 за сентябрь. База уже заполнена, остался один запрос — используйте SUM, COUNT и фильтр по префиксу месяца.

practice.py
Вопросы и ответы по уроку

Как запустить телеграм-бота на Python на своём компьютере?

Установите Python 3.10+, создайте виртуальное окружение и поставьте пакет: pip install aiogram. Получите токен у @BotFather (команда /newbot), сохраните его в переменную окружения BOT_TOKEN и запустите скрипт командой python bot.py — бот начнёт работать через long polling, сервер не нужен. Чек-лист целиком — в уроке 10 курса.

Где хранить данные телеграм-бота?

Для учебных и небольших ботов достаточно SQLite — файла рядом со скриптом, который не требует сервера и поддерживает SQL. Данные привязывайте к chat_id, чтобы пользоваться ботом и в личке, и в группах. Когда пользователей становятся десятки тысяч или база переезжает на другой сервер, SQLite меняют на PostgreSQL — схема таблиц обычно переносится почти без изменений.

Как сделать в боте диалог с кнопками и состояниями?

Через машину состояний: команда /add переводит бота в состояние «ждём сумму», ответ сохраняется в FSM-хранилище (state.update_data), затем бот переходит в состояние «ждём категорию» и показывает inline-кнопки. Нажатие кнопки ловится callback-хендлером с фильтром по состоянию, данные достаются через get_data, после сохранения состояние сбрасывается. Рабочий пример — трекер расходов из урока 10.

Что изучать после курса по aiogram?

Три направления: углубить aiogram (RedisStorage вместо MemoryStorage, вебхуки вместо polling, middlewares сложнее троттлинга), прокачать данные (SQL запросами сложнее GROUP BY, миграции схемы) и научиться деплоить — systemd или Docker на дешёвом VPS, чтобы бот работал круглосуточно. Схожий по устройству веб-проект с базой данных есть в курсе Flask — идеи состояний и хранилища там те же.

Понравился урок? Сошлитесь на него

«Проект хорош, когда доведён до конца: работающий бот с одной таблицей лучше недописанного монстра с пятью.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.