Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 25 из 25 ~24 мин +50 XP

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

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

Содержание урока · 10 разделов

Соберём всё, что накопилось за курс, в одного бота, которым вы реально будете пользоваться: трекер привычек. Сценарий простой и честный: утром бот напоминает про привычки, человек отвечает /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 за пару строк.

Напоминания тикером из урока 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урок 17429 в логах, пауза, продолжение без потерь
Напоминания тикеромурок 19Рестарт не теряет привычки
Тесты и деплойуроки 23-24go test зелёный, systemd поднимает после kill

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

Почему сохранение 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)
}

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

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

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)

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

Store

нажмите, чтобы перевернуть
Определение

Карта map[int64][]Habit под sync.Mutex с методами Add, Done, List, Pending. Наружу карта не выставляется — гонки исключены по построению.

1 / 6

Проект собран, и курс на этом завершён — но у продукта остаются направления роста, и каждое уже имеет адрес в курсе: монетизация через инвойсы и звёзды — премиум-план трекера; 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, поставьте напоминание на завтра и пользуйтесь ботом неделю — список неудобств станет планом версии два.

Личные заметкиПусто
Хранятся только в вашем браузере (Telegram · Итоговый проект: бот-трекер привычек)

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

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

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

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

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

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

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

Термины урока

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

Следующая ступень учебного пути

Prometheus25 уроков~12 чпрактика25 уроков уже доступны

Мониторинг Go с Prometheus: метрики, Grafana, алерты

Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.

  • Понимать четыре типа метрик и выбирать правильный
  • Экспортировать метрики из Go через promhttp
  • Инструментировать HTTP-запросы и бизнес-события
  • Писать PromQL-запросы: rate, sum, p99