---
title: "Итоговый проект: бот-трекер привычек"
description: "Итоговый проект на Go: телеграм-бот трекер привычек — /add, /list и /done, состояние диалога, сохранение в JSON, напоминания тикером и структура проекта."
course: "Telegram-боты на Go: от эха до полноценного сервиса"
url: "https://golangmaster.ru/courses/telegram-bot/lessons/25-itogovyj-proekt-bot/"
published: "2026-09-19"
updated: "2026-09-25"
minutes: 24
---

# Итоговый проект: бот-трекер привычек

*Курс «Telegram-боты на Go: от эха до полноценного сервиса», урок 25 из 25. ~24 минут. Обновлено 25.09.2026. Источник: https://golangmaster.ru/courses/telegram-bot/lessons/25-itogovyj-proekt-bot/*

Итоговый проект курса — телеграм-бот трекер привычек на Go: команды /add, /list и /done, состояние для ввода названия привычки, сохранение в JSON-файл и напоминания тикером. За двадцатью четырьмя уроками накопились все детали — здесь они собираются в законченный продукт, который можно выложить на VPS и пользоваться каждый день.

Соберём всё, что накопилось за курс, в одного бота, которым вы реально будете пользоваться: трекер привычек. Сценарий простой и честный: утром бот напоминает про привычки, человек отвечает /done, бот увеличивает серию дней и тихо радуется огонёком; вечером — сводка. Такой проект хорош именно для итога: у него есть пользователи-люди с вводом любой степени странности, долгоживущие данные, которые обязаны переживать рестарт, фоновая задача и деплой на сервер. Каждый компонент уже разобран в своём уроке — сейчас работа сборочная: определиться со структурой проекта, свести команды, состояния, хранилище и тикер в один процесс и не наступать на стыки, где компоненты встречаются впервые. Написание займёт вечер; дальше — разбор каждого узла с кодом и две ошибки на стыках, которые в этом проекте делают почти все.

### Структура проекта: маленькая и плоская

1. main.go — токен из окружения, создание бота, запуск цикла апдейтов и тикерной горутины, graceful-остановка
2. habits.go — структура Habit и хранилище Store: карта под мьютексом, Add, List, MarkDone, Pending
3. storage.go — сериализация в JSON и атомарная запись: Marshal, временный файл, Rename, загрузка при старте
4. dialog.go — машина состояний из урока про диалог: ожидание названия привычки, сбросы, подсказки
5. reminders.go — тикерная горутина: раз в минуту сверяет привычки и рассылает напоминания по зоне пользователя
6. dialog_test.go — table-driven тесты хендлера из урока про тесты: пустой Update, команды без аргументов, обрывы диалога

### Команды: /add, /list и /done

Поверхность продукта — три команды, и каждая прощает пользователя максимум. /add заводит привычку: с аргументом — сразу, без аргумента — бот спрашивает название и ждёт следующее сообщение. /list показывает привычки с серией дней и inline-кнопками отметить выполненные сегодня: одна кнопка, одно нажатие, и сера растёт. /done <название> отмечает выполнение текстом — для тех, кто любит команды больше кнопок. Сознательные решения по UX из практики: названия привычек бот принимает любыми, включая эмодзи, но ограничивает длину разумным максимумом, чтобы кнопки не ломались; серия считается по календарным дням зоны пользователя, а не по суткам от момента добавления; повторное /done в один день не удваивает ничего — день либо отмечен, либо нет. Форматирование списка — через HTML-разметку из урока про форматирование, с экранированием пользовательских названий: привычка под названием «<мама>» не должна сломать разметку всего сообщения.

### Привычка и хранилище

```go
type Habit struct {
        ID       string
        ChatID   int64
        Title    string
        Streak   int
        LastDone time.Time
}

type Store struct {
        mu   sync.Mutex
        path string
        data map[int64][]Habit
}

func (s *Store) Add(chatID int64, title string) {
        s.mu.Lock()
        defer s.mu.Unlock()
        h := Habit{ID: fmt.Sprintf("h%d", time.Now().UnixNano()),
                ChatID: chatID, Title: title}
        s.data[chatID] = append(s.data[chatID], h)
        _ = s.save()
}

func (s *Store) Done(chatID int64, id string) {
        s.mu.Lock()
        defer s.mu.Unlock()
        for i := range s.data[chatID] {
                h := &s.data[chatID][i]
                if h.ID != id || doneToday(h) {
                        continue
                }
                h.Streak++
                h.LastDone = time.Now()
        }
        _ = s.save()
}
```

Теги полей не нужны: json.Marshal без тегов использует имена полей как ключи, и этого достаточно для личного хранилища. Идентификатор привычки строится из времени — простого и уникального в рамках одного бота, он же идёт в callback data кнопок. Все методы захватывают мьютекс и вызывают save до выхода: хранилище никогда не держит изменённые данные только в памяти.

### Состояние ввода: /add по шагам

Ввод названия привычки — это тот же FSM из урока про состояние диалога, только с одним шагом. Машина хранится картой чатов: stepIdle — бот ничего не ждёт, stepAwaitTitle — ждёт название после /add без аргументов. Хендлер проверяет команды в первую очередь и только потом состояние: это сохраняет работоспособность остальных команд во время диалога, но приносит ловушку, разобранную ниже в predict-задании. Диалог живёт недолго: название получено — состояние сброшено, бот подтверждает. Полезная привычка проектирования: даже одношаговая анкета описывается константами состояний и одной картой, а не булевыми флагами в воздухе — при добавлении второго шага, например выбора времени напоминания, структура не переделывается, а расширяется шагом stepAwaitTime, и в этом весь смысл паттерна, ради которого он попал в середину курса.

### Хендлер с состояниями

```go
const (
        stepIdle = iota
        stepAwaitTitle
)

func handleUpdate(u tgbotapi.Update, st *State, store *Store) string {
        m := u.Message
        if m == nil {
                return ""
        }
        chat := m.Chat.ID
        switch m.Command() {
        case "add":
                if args := m.CommandArgs(); args != "" {
                        store.Add(chat, args)
                        return "Привычка заведена: " + args
                }
                st.Set(chat, stepAwaitTitle)
                return "Как назовём привычку? Например: зарядка"
        case "list":
                return listHabits(store, chat)
        case "done":
                return markDone(store, chat, m.CommandArgs())
        }
        if st.Get(chat) == stepAwaitTitle {
                store.Add(chat, m.Text)
                st.Reset(chat)
                return "Привычка заведена: " + m.Text
        }
        return helpText
}
```

Транспорт отдельно: цикл апдейтов из main вызывает handleUpdate и отправляет возвращённую строку — логика тестируется без сети, как в уроке про тесты. Ветвь state-проверки стоит после switch: команды всегда обрабатываются, а любое обычное сообщение в момент ожидания становится названием. Пустой ответ для не-сообщений защищает хендлер от колбэков и отредактированных сообщений.

### Сохранение в JSON: атомарная запись

Для персонального бота JSON-файл — честный выбор: ноль зависимостей, читается глазами, бэкапится копированием, а объём данных в трекере привычек измеряется килобайтами. Дисциплина та же, что в уроке про хранение данных: карта под мьютексом, наружу — методы, а формат сериализации — деталь внутри. Критична одна операция — запись: наивный os.WriteFile прямо в основной файл при обрыве питания или убийстве процесса посреди записи оставляет файл обрезанным или пустым, и вместе с ним исчезают все привычки всех пользователей. Атомарная запись из двух шагов решает это полностью: сначала полный файл пишется под временным именем, затем os.Rename переименовывает его в основной — rename на той же файловой системе атомарен, и в любой момент времени основной файл либо старый целиком, либо новый целиком. Загрузка при старте читает файл и заполняет карту; испорченный файл — сигнал восстановиться из бэкапа, поэтому простая ротация из трёх копий по дням пишется в тот же save за пару строк.

> **Живое место: пустой habits.json после падения.** Классика первого деплоя: трекер неделю работает, сервер перезагружается в момент записи — и после старта бот отвечает на /list пустотой, потому что файл обрезан, а карта загрузилась пустой. Владелец теряет не код, а данные пользователей, и восстановить их неоткуда. Лечение двухуровневое. Немедленное: атомарная запись tmp плюс rename — пустой файл после падения становится невозможным по построению. Системное: ротация бэкапов при каждом сохранении — раз в день архивная копия, и потеря не бывает полной никогда. Вторая ошибка на этом же стыке — save внутри хендлеров при каждом апдейте: при активном чате файл пишется десятки раз в минуту, и на слабом диске это заметно; группировка записи по факту изменения, а не по факту запроса, снимает нагрузку почти целиком.

### Напоминания тикером из урока 19

Фоновая половина продукта — горутина с тикером, которая раз в минуту сверяет привычки с временем и рассылает напоминания. Схема та же, что в уроке про напоминания: time.NewTicker, select на тик и ctx.Done, выборка под замком, отправка после. Отличие проектное: у напоминания-будильника был точный момент срабатывания, у привычки — окно дня, поэтому логика сводится к проверке отмечена ли привычка сегодня: если нет и наступило время напоминания, configured в настройках чата, бот пишет. Время пользователя берётся из зоны: для первого релиза честно хранится смещение из команды /tz, а тонкость с переводом часов живёт в уроке про напоминания целиком. Гонки исключены мьютексом хранилища: тикерная горутина и цикл апдейтов делят одну Store, и ни одна из них не трогает карту напрямую — только методы. Остановка — контекст из main: сигнал завершения гасит и цикл апдейтов, и тикер, и процесс умирает аккуратно, успевая дописать файл.

### Тикер напоминаний

```go
func reminderLoop(ctx context.Context, bot Sender, store *Store, zone *time.Location) {
        t := time.NewTicker(time.Minute)
        defer t.Stop()
        for {
                select {
                case <-ctx.Done():
                        return
                case now := <-t.C:
                        for _, h := range store.Pending(now.In(zone)) {
                                msg := tgbotapi.NewMessage(h.ChatID,
                                        "Пора: "+h.Title)
                                _, _ = bot.Send(msg)
                        }
                }
        }
}
```

Sender вместо BotAPI — тот самый интерфейс из урока про тесты: в тесте reminderLoop прогоняется с фейком и подтверждает, что напоминание ушло нужному чату один раз. store.Pending возвращает привычки, не отмеченные сегодня, и делает это под замком — отправка идёт уже по срезу-копии. Тик в минуту даёт точность, которой достаточно для напоминаний про привычки.

### Inline-кнопки для /done

Список привычек без кнопок заставляет человека набирать /done и название — а кнопка делает то же действие одним нажатием. Схема из урока про inline-клавиатуры: каждая строка списка получает InlineKeyboardButtonData с данными вида done:h12 — префикс и идентификатор привычки, — хендлер CallbackQuery разбирает префикс, вызывает store.Done и отвечает AnswerCallbackQuery, чтобы кнопка перестала крутиться. Сообщение после нажатия перерисовывается через EditMessageText из урока про редактирование: серия выросла, галочка переставлена, и история чата не засоряется новым сообщением на каждое нажатие. Дважды отметить одну привычку в день невозможно: Done проверяет doneToday и молча ничего не делает, а кнопка после обработки просто уходит. Тонкое место — старые сообщения со списками: кнопки в них живут вечно, и нажатие на вчерашний список должно отвечать вежливым «это прошлое», а не менять сегодняшнюю серию — дата списка кодируется в callback data рядом с идентификатором.

### Чек-лист проекта: фича и её урок

| Фича проекта | Где разобрана | Проверка в проде |
| --- | --- | --- |
| Команды и меню | урок 07 про команды | /help отвечает списком |
| Кнопки и inline | уроки 08-09 | Список с кнопками, AnswerCallbackQuery без крутилок |
| Состояния диалога | урок 10 | Обрыв диалога не ломает остальные команды |
| JSON-хранилище | уроки 11 и этот | kill -9 в момент записи не портит файл |
| Экранирование ввода | урок 13 | Привычка со скобками не ломает разметку |
| Лимиты и ошибки API | урок 17 | 429 в логах, пауза, продолжение без потерь |
| Напоминания тикером | урок 19 | Рестарт не теряет привычки |
| Тесты и деплой | уроки 23-24 | go test зелёный, systemd поднимает после kill |

> **Что тренируется в песочнице.** Файловый ввод-вывод, сеть и запуск тестов требуют локального Go: песочница самоучителя поддерживает только fmt, strings, strconv, math, sort, errors и time, поэтому Store и тикер проверяются у себя. Но ядро проекта переносимо: логика серий, парсинг callback data и разбор аргументов — это чистые функции над строками и структурой, и их удобно тренировать в песочнице, собирая привычки в карте и печатая сводку. Порядок внедрения: сначала команды на живом боте локально, затем хранилище, затем тикер, деплой последним — каждый шаг проверяется отдельно, и ошибка остаётся в слое, где появилась.

### Проверка: атомарная запись

**Вопрос:** Почему сохранение JSON идёт во временный файл, а затем os.Rename?

- Rename работает быстрее, чем WriteFile
- Так файл сжимается на диске
- ✅ Запись в основной файл при обрыве оставляет его пустым; rename заменяет файл целиком и атомарно
- Так экономится место в файловой системе

os.WriteFile в основной файл обрезает его в начале записи: обрыв процесса посреди записи оставляет пустой или частичный файл, и все данные теряются. Rename на той же файловой системе заменяет файл одним шагом: в любой момент основной файл — либо старая полная версия, либо новая полная.

### Проверка: две горутины и одна карта

**Вопрос:** Цикл апдейтов пишет привычки в Store, тикерная горутина читает Pending. Что требуется?

- Ничего: Store — обычная структура, доступ последовательный
- Канал между горутинами вместо карты
- ✅ Мьютекс внутри методов Store: обе горутины делят одну карту
- Копия хранилища для тикера раз в минуту

Две горутины делят одну карту, а одновременные запись и чтение карты — фатальная ошибка процесса. Мьютекс в методах Store закрывает вопрос по построению: наружу карта не выставляется, обе горутины работают только через Add, Done и Pending.

### Диалог и команда в очереди

```go
// пользователь послал /add без аргументов — бот ждёт название
// st.Get(chat) == stepAwaitTitle
// следующий апдейт: m.Text == "/list"

switch m.Command() {
case "add", "list", "done", "help":
        // команды обрабатываются первыми
}
if st.Get(chat) == stepAwaitTitle {
        store.Add(chat, m.Text)
        st.Reset(chat)
}
```

**Что выведет программа?**

- Бот покажет список и сбросит ожидание названия
- ✅ Бот покажет список, но состояние останется: следующее обычное сообщение станет привычкой
- Бот добавит привычку с названием /list
- Бот проигнорирует апдейт: состояние сильнее команд

Команды проверяются раньше состояния, поэтому /list сработает как команда, а состояние ожидания останется висеть: следующее сообщение с любым текстом станет названием привычки. Лечение — сбрасывать шаг при входе любой команды: st.Reset(chat) в начале ветки команд, и диалог не переживает чужие команды.

### Соберите атомарное сохранение

Впишите вызовы сериализации и записи файла.

```go
b, err := json.___(s.data)
if err != nil {
        return err
}
tmp := s.path + ".tmp"
if err := os.___(tmp, b, 0o600); err != nil {
        return err
}
return os.___(tmp, s.path)
```

Подсказка: Карту сериализует json.Marshal, временный файл пишет os.WriteFile, а атомарную замену делает os.Rename.

Правильные ответы в пропусках: `Marshal`, `WriteFile`, `Rename`

### Соберите путь команды /add без аргументов

Расставьте строки: от установки состояния до подтверждения.

Правильный порядок строк:

1. `st.Set(chat, stepAwaitTitle)`
2. `return "Как назовём привычку?"`
3. `// следующий апдейт из того же чата`
4. `if st.Get(chat) == stepAwaitTitle {`
5. `store.Add(chat, m.Text)`
6. `st.Reset(chat)`

### Карточки: итоговый проект

- **Store** — Карта map[int64][]Habit под sync.Mutex с методами Add, Done, List, Pending. Наружу карта не выставляется — гонки исключены по построению.
- **Атомарная запись** — json.Marshal, os.WriteFile во временный файл, os.Rename в основной. Обрыв процесса не оставляет пустой файл — данные переживают падение.
- **stepAwaitTitle** — Состояние диалога после /add без аргументов: бот ждёт название следующим сообщением. Сбрасывается при получении названия и при входе любой команды.
- **Pending** — Выборка привычек, не отмеченных сегодня, под замком: её забирает тикерная горутина раз в минуту и рассылает напоминания.
- **callback done:id** — Схема callback data кнопок списка: префикс действия и идентификатор привычки. Обработка — store.Done, AnswerCallbackQuery и перерисовка через EditMessageText.
- **Sender** — Интерфейс отправки из урока про тесты: прод получает BotAPI, тест — фейк со срезом вызовов. Тикер и хендлеры тестируются без сети.

Проект собран, и курс на этом завершён — но у продукта остаются направления роста, и каждое уже имеет адрес в курсе: [монетизация через инвойсы и звёзды](/courses/telegram-bot/lessons/20-platezhi-obzor/) — премиум-план трекера; [Mini App](/courses/telegram-bot/lessons/21-mini-apps-obzor/) — экран статистики с графиками, когда кнопок станет мало; [рассылки](/courses/telegram-bot/lessons/14-rassylki/) — еженедельная сводка по всем чатам с вежливыми паузами. Вернуться к узлам проекта: [состояние диалога](/courses/telegram-bot/lessons/10-sostoyanie-dialoga/), [хранение данных](/courses/telegram-bot/lessons/11-hranenie-dannyh/), [напоминания](/courses/telegram-bot/lessons/19-napominaniya/), [тесты](/courses/telegram-bot/lessons/23-testy-bota/) и [деплой](/courses/telegram-bot/lessons/24-deploy-bota/) — они же чек-лист ревизии, когда бот начнёт вести не только ваши привычки. Полный маршрут всех двадцати пяти уроков — [на странице курса](/courses/telegram-bot/).

## Коротко: что запомнить

- Трекер привычек собирается из деталей курса: команды, FSM на один шаг, JSON-хранилище под мьютексом и тикерная горутина.
- Структура плоская: main, habits, storage, dialog, reminders, тесты — каждый файл отвечает за свой слой.
- Сохранение атомарно: Marshal, WriteFile во временный файл, Rename — падение посреди записи не теряет данные.
- Команды проверяются раньше состояния, но шаг диалога обязан сбрасываться при входе любой команды.
- Тикер раз в минуту выбирает Pending под замком и рассылает через Sender-интерфейс: тесты идут без сети.
- Inline-кнопки /done используют callback data done:id и перерисовку EditMessageText — один день, одна отметка.

## Домашнее задание

Соберите проект целиком: структуры Habit и Store с атомарным сохранением, машину состояний для /add, команды /list и /done с inline-кнопками и тикер напоминаний. Прогоните table-driven тесты из урока про тесты — минимум на пустой Update, диалог с командой посередине и повторное /done. Проверьте выживание данных: заполните трекер, убейте процесс kill -9 в момент сохранения и убедитесь, что после рестарта привычки на месте. Затем задеплойте на VPS по уроку про systemd, поставьте напоминание на завтра и пользуйтесь ботом неделю — список неудобств станет планом версии два.

## Вопросы и ответы по уроку

### Почему JSON-файл, а не база данных?

У персонального трекера объём данных в килобайтах, запросов в минуту — единицы, а JSON читается глазами и бэкапится копированием. База станет правильной с многими пользователями, транзакциями и отчётами — переход на Redis или SQLite описан в уроке про хранение данных и не требует менять хендлеры, только методы Store.

### Как считать серию дней корректно?

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

### Что добавить первым, если бот станет популярным?

Порядок по цене ошибки: бэкапы и ротация файла, затем идемпотентность и лимиты при росте числа чатов, затем переход хранилища на базу. Монетизация и Mini App — следующий шаг только после того, как данные перестали быть в опасности: прибыль с потерянных данных не работает.
