Проект: телеграм-бот трекер расходов на aiogram с базой данных
Сквозной проект курса: бот-трекер расходов с inline-кнопками, FSM-диалогом, SQLite-хранилищем и отчётом за месяц. Рабочая версия в песочнице, полный код на aiogram и чек-лист запуска.
Редакция Питоники
Девять уроков позади: хендлеры и фильтры, клавиатуры, машина состояний, SQLite, асинхронность, middleware и ошибки. По отдельности всё работает — теперь соберём это в законченную вещь, которой можно пользоваться каждый день: телеграм-бота-трекер расходов. Написал /add, ткнул в категорию — вечером /report показывает, куда утекает зарплата.
Формат урока такой: сначала техзадание, потом каждый механизм проекта запускается прямо на странице — песочница умеет настоящую sqlite3, и главный интерактив сегодня покажет живой диалог с ботом. Затем — полный код на aiogram 3 одним листингом, структура файлов и чек-лист запуска на своём компьютере с токеном от BotFather.
Что будет уметь бот: техническое задание
Начнём как в настоящем проекте — с договора о том, что считаем готовым результатом. Шесть пунктов, ничего лишнего:
- Команда /start — приветствие и подсказка по командам.
- Команда /add запускает диалог: бот спрашивает сумму, потом категорию из inline-кнопок.
- Сумма принимается с запятой или точкой («149,90» тоже деньги), мусор вроде «двести» вежливо отклоняется.
- Каждая трата сохраняется в SQLite с chat_id — ботом можно пользоваться и в личке, и в группах.
- Команда /report — сводка за месяц: сумма по каждой категории, от большей к меньшей.
- Бот не падает на кривом вводе: за это отвечает try/except из урока 9.
Как устроена база данных: одна таблица расходов
Для трекера не нужна модель данных на десять таблиц: трата — это сумма, категория, кто и когда. Всё хранится в одной таблице, и chat_id в ней — главный ключ многопользовательности: именно по нему отчёт отделяет ваши траты от чужих, если бот живёт в групповом чате.
| Поле | Тип | Зачем |
|---|---|---|
| id | INTEGER PRIMARY KEY | номер записи, нужен SQLite для порядка |
| chat_id | INTEGER | из какого чата пришла трата — фильтр отчёта |
| amount | REAL | сумма в рублях, уже проверенная парсером |
| category | TEXT | Еда, Транспорт, Развлечения, Другое |
| created | TEXT | дата в формате YYYY-MM-DD — по ней ищем месяц |
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER, -- чат, из которого пришла трата
amount REAL, -- сумма в рублях
category TEXT, -- Еда, Транспорт, Развлечения, Другое
created TEXT -- дата траты в формате YYYY-MM-DD
);
Как принять сумму и не упасть на слове «двести»?
Первое место, где проект ломается вживую, — ввод суммы. Люди пишут «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». Сгруппируем траты по категории и посчитаем суммы:
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 и прогоним через него настоящий диалог — включая ошибки пользователя:
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, подставьте токен — и пользуйтесь. Каждый блок вы уже видели по отдельности в песочнице выше или в прошлых уроках.
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())
Пройдитесь по листингу как по карте курса. 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 3.10 или новее (python.org, при установке отметьте «Add to PATH»).
- Создайте папку проекта и виртуальное окружение:
python -m venv venvи активируйте его. - Установите aiogram:
pip install aiogram. - В Telegram откройте @BotFather, отправьте /newbot, придумайте имя — получите токен вида
123456789:AA.... - Сохраните токен в переменную окружения BOT_TOKEN (не в код!).
- Положите рядом bot.py из листинга и запустите:
python bot.py. - Найдите своего бота в 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
Итоги курса: что вы теперь умеете
Откатитесь к уроку 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)
1. Почему в таблице расходов chat_id, а не имя пользователя?
2. Почему значения вставляются через заполнители (?, ?, ?, ?), а не через f-строку в SQL?
3. Что делает строка GROUP BY category в запросе отчёта?
4. Пользователь на шаге суммы прислал «двести». Что делает правильный хендлер?
5. Что произойдёт с незаконченными FSM-диалогами, если перезапустить бота с MemoryStorage?
Добавьте трекеру сводку по одной категории: посчитайте сумму и число трат категории «Еда» для чата 101 за сентябрь. База уже заполнена, остался один запрос — используйте SUM, COUNT и фильтр по префиксу месяца.
Как запустить телеграм-бота на 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
aiogram · Урок 3
Клавиатуры в телеграм-боте: reply и inline кнопки в aiogram
Строим кнопки, которыми приятно пользоваться: ReplyKeyboardMarkup против InlineKeyboardMarkup, ряды, callback_data и обработка нажатий в aiogram 3.
aiogram · Урок 4
FSM в aiogram: машина состояний для пошаговых диалогов
Учим бот вести диалог по шагам: состояния как таблица переходов, анкета «имя — возраст — город» на тренажёре, а потом по-настоящему — StatesGroup, MemoryStorage и FSMContext.
aiogram · Урок 6
База данных в телеграм-боте: SQLite от первого лица
Даём боту настоящую память: таблица пользователей с chat_id, запись через INSERT OR IGNORE, чтение по chat_id и параметр ? против SQL-инъекций.
Flask · Урок 10
Проект: блог на Flask с постами и комментариями
Финальный проект курса: блог на Flask с постами, комментариями и пагинацией. Модели, CRUD-маршруты, наследование шаблонов, деплой на PythonAnywhere и разбор каждого решения.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
FastAPI · Урок 10
Проект: REST API сервиса заметок с базой данных
Сквозной проект курса: сервис заметок с тегами, поиском, SQLite и JWT. Структура по файлам, контракты Pydantic, тесты и README — портфолио-проект за пару вечеров.
fastapi проектfastapi crud с базой данных
sqlite3 · Урок 10
Проект: база книжной библиотеки на SQLite с отчётами SQL
Финальный проект курса: база библиотеки из трёх связанных таблиц — авторы, книги, выдачи — и четыре настоящих отчёта на SQL. Собираем всё, что выучили за девять уроков.
sqlite python проектбаза данных книг sqlite
aiogram · Урок 1
Как создать телеграм-бота на Python: от BotFather до первого эхо-ответа
Регистрируем бота в BotFather, разбираемся, что такое токен и long polling, и собираем эхо-бота на aiogram — механику проверяем прямо в браузере, без токена.
как создать телеграм бота на pythonтелеграм бот python