Содержание урока · 10 разделов
Соберём всё, что накопилось за курс, в одного бота, которым вы реально будете пользоваться: трекер привычек. Сценарий простой и честный: утром бот напоминает про привычки, человек отвечает /done, бот увеличивает серию дней и тихо радуется огонёком; вечером — сводка. Такой проект хорош именно для итога: у него есть пользователи-люди с вводом любой степени странности, долгоживущие данные, которые обязаны переживать рестарт, фоновая задача и деплой на сервер. Каждый компонент уже разобран в своём уроке — сейчас работа сборочная: определиться со структурой проекта, свести команды, состояния, хранилище и тикер в один процесс и не наступать на стыки, где компоненты встречаются впервые. Написание займёт вечер; дальше — разбор каждого узла с кодом и две ошибки на стыках, которые в этом проекте делают почти все.
Структура проекта: маленькая и плоская#
- main.go — токен из окружения, создание бота, запуск цикла апдейтов и тикерной горутины, graceful-остановка
- habits.go — структура Habit и хранилище Store: карта под мьютексом, Add, List, MarkDone, Pending
- storage.go — сериализация в JSON и атомарная запись: Marshal, временный файл, Rename, загрузка при старте
- dialog.go — машина состояний из урока про диалог: ожидание названия привычки, сбросы, подсказки
- reminders.go — тикерная горутина: раз в минуту сверяет привычки и рассылает напоминания по зоне пользователя
- dialog_test.go — table-driven тесты хендлера из урока про тесты: пустой Update, команды без аргументов, обрывы диалога
Команды: /add, /list и /done#
Поверхность продукта — три команды, и каждая прощает пользователя максимум. /add заводит привычку: с аргументом — сразу, без аргумента — бот спрашивает название и ждёт следующее сообщение. /list показывает привычки с серией дней и inline-кнопками отметить выполненные сегодня: одна кнопка, одно нажатие, и сера растёт. /done <название> отмечает выполнение текстом — для тех, кто любит команды больше кнопок. Сознательные решения по UX из практики: названия привычек бот принимает любыми, включая эмодзи, но ограничивает длину разумным максимумом, чтобы кнопки не ломались; серия считается по календарным дням зоны пользователя, а не по суткам от момента добавления; повторное /done в один день не удваивает ничего — день либо отмечен, либо нет. Форматирование списка — через HTML-разметку из урока про форматирование, с экранированием пользовательских названий: привычка под названием «<мама>» не должна сломать разметку всего сообщения.
Привычка и хранилище
GOtype 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, и в этом весь смысл паттерна, ради которого он попал в середину курса.
Хендлер с состояниями
GOconst (
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 за пару строк.
Классика первого деплоя: трекер неделю работает, сервер перезагружается в момент записи — и после старта бот отвечает на /list пустотой, потому что файл обрезан, а карта загрузилась пустой. Владелец теряет не код, а данные пользователей, и восстановить их неоткуда. Лечение двухуровневое. Немедленное: атомарная запись tmp плюс rename — пустой файл после падения становится невозможным по построению. Системное: ротация бэкапов при каждом сохранении — раз в день архивная копия, и потеря не бывает полной никогда. Вторая ошибка на этом же стыке — save внутри хендлеров при каждом апдейте: при активном чате файл пишется десятки раз в минуту, и на слабом диске это заметно; группировка записи по факту изменения, а не по факту запроса, снимает нагрузку почти целиком.
Напоминания тикером из урока 19#
Фоновая половина продукта — горутина с тикером, которая раз в минуту сверяет привычки с временем и рассылает напоминания. Схема та же, что в уроке про напоминания: time.NewTicker, select на тик и ctx.Done, выборка под замком, отправка после. Отличие проектное: у напоминания-будильника был точный момент срабатывания, у привычки — окно дня, поэтому логика сводится к проверке отмечена ли привычка сегодня: если нет и наступило время напоминания, configured в настройках чата, бот пишет. Время пользователя берётся из зоны: для первого релиза честно хранится смещение из команды /tz, а тонкость с переводом часов живёт в уроке про напоминания целиком. Гонки исключены мьютексом хранилища: тикерная горутина и цикл апдейтов делят одну Store, и ни одна из них не трогает карту напрямую — только методы. Остановка — контекст из main: сигнал завершения гасит и цикл апдейтов, и тикер, и процесс умирает аккуратно, успевая дописать файл.
Тикер напоминаний
GOfunc 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?
Проверка: две горутины и одна карта
Цикл апдейтов пишет привычки в Store, тикерная горутина читает Pending. Что требуется?
Диалог и команда в очереди
Прочитайте код и выберите, что он выведет:
// пользователь послал /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)
}Соберите атомарное сохранение
Впишите вызовы сериализации и записи файла.
Соберите путь команды /add без аргументов
Расставьте строки: от установки состояния до подтверждения.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: итоговый проект
Проект собран, и курс на этом завершён — но у продукта остаются направления роста, и каждое уже имеет адрес в курсе: монетизация через инвойсы и звёзды — премиум-план трекера; Mini App — экран статистики с графиками, когда кнопок станет мало; рассылки — еженедельная сводка по всем чатам с вежливыми паузами. Вернуться к узлам проекта: состояние диалога, хранение данных, напоминания, тесты и деплой — они же чек-лист ревизии, когда бот начнёт вести не только ваши привычки. Полный маршрут всех двадцати пяти уроков — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Трекер привычек собирается из деталей курса: команды, 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 — следующий шаг только после того, как данные перестали быть в опасности: прибыль с потерянных данных не работает.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Мониторинг Go с Prometheus: метрики, Grafana, алерты
Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.
- Понимать четыре типа метрик и выбирать правильный
- Экспортировать метрики из Go через promhttp
- Инструментировать HTTP-запросы и бизнес-события
- Писать PromQL-запросы: rate, sum, p99