Хотел взять готовый Go-клиент для Max. Итог предсказуем
- воскресенье, 22 февраля 2026 г. в 00:00:10

Если у вас есть бот в Телеграме, то наверняка уже поглядываете в сторону Max — аудитория растёт, игнорировать сложно.
Первая мысль: наверняка кто-то уже написал удобный Go-клиент. Поиск выдал пару заброшенных репозиториев и официальный клиент, который хоть как-то поддерживается. Выбор очевиден — беру официальный, начинаю писать бота... и через пару часов понимаю: «быстренько» не получится. К API вопросов нет — он понятный и логичный. А вот клиент преподнёс «неожиданности»: нет context.Context, нет конструкторов для кнопок, а инлайн-клавиатура молча исчезает при редактировании сообщения.
Чем всё закончилось, вы уже догадались — своим клиентом. OpenAPI-схема та же, что у официального — почему бы не попробовать сделать лучше? Расскажу, что вышло.
Чего хотелось? Что-то в стиле telebot — роутер, middleware, удобный контекст:
bot.Handle("/start", func(ctx bot.Context) error { return ctx.Reply("Привет!") })
Для Max такого не нашлось. А первый шаг к этому — нормальный клиент. Про «неожиданности» я уже упомянул. Вот та, что стала отправной точкой.
Задача простая: отправить сообщение с инлайн-кнопкой, обработать нажатие, отредактировать текст. Отправляю — кнопка есть. Пользователь нажимает — callback приходит. Редактирую текст в ответ... и кнопка пропадает.
Полчаса дебага. Перечитываю документацию API. Проверяю свой код. Всё выглядит правильно. А кнопка исчезает.
Оказалось, дело вот в чём:
type NewMessageBody struct { Text string `json:"text,omitempty"` Attachments []interface{} `json:"attachments"` // ← без omitempty! } func NewMessage() *Message { return &Message{ message: &schemes.NewMessageBody{ Attachments: []interface{}{}, // ← всегда пустой слайс }, } }
Конструктор всегда создаёт пустой слайс Attachments, а в JSON-теге нет omitempty. При каждом запросе отправляется "attachments": [] — даже если вы просто хотите поменять текст. А по документации API пустой массив означает «удалить все вложения». Включая инлайн-клавиатуру.
Тридцать минут на баг, которого не должно было быть. Что ж, «нормальный» клиент придётся писать самому.
Что было на руках? OpenAPI-схема v0.0.10 — та же, что у официального клиента. Из неё сгенерировал типы и эндпоинты. Но схема оказалась неполной: кнопка open_app для мини-приложений отсутствует, пять типов обновлений (bot_stopped, dialog_muted, dialog_unmuted, dialog_cleared, dialog_removed) — тоже.
Пришлось сверяться с dev.max.ru вручную. Сайт — SPA на React, обычным парсером не возьмёшь. Но через RSC-протокол вытащил данные со всех 35 страниц документации, прогнал diff — нашёл пропуски, дополнил типы.

Итого: OpenAPI как основа, dev.max.ru как источник правды, живое API для проверки. Дальше — несколько правил для себя:
Первое — никаких зависимостей. Только stdlib: net/http, encoding/json, context. HTTP-клиент не должен тащить за собой половину интернета.
Второе — ошибки возвращаются. Никаких log.Println в defer. Что-то пошло не так — вызывающий код решает, что с этим делать.
Третье — context.Context в каждом методе. Хотите таймаут? context.WithTimeout. Хотите отменить? context.WithCancel. Стандартный подход, ничего нового.
Четвёртое �� тестируемость. Одна строка — и все запросы идут на mock-сервер:
client, _ := maxigo.New("token", maxigo.WithBaseURL(srv.URL))
Никаких http.DefaultClient внутри, никаких скрытых зависимостей.
Установка:
go get github.com/maxigo-bot/maxigo-client
Отправка первого сообщения:
client, err := maxigo.New("YOUR_BOT_TOKEN") if err != nil { log.Fatal(err) } msg, err := client.SendMessageToUser(context.Background(), userID, &maxigo.NewMessageBody{ Text: maxigo.Some("Привет из maxigo-client!"), }) if err != nil { log.Fatal(err) } fmt.Printf("Отправлено: %s\n", msg.Body.MID)
maxigo.Some("") — не прихоть, а решение реальной проблемы. Но об этом чуть позже.
Кнопки — первое, что хочется добавить в бота:
msg, err := client.SendMessage(ctx, chatID, &maxigo.NewMessageBody{ Text: maxigo.Some("Выберите действие:"), Attachments: []maxigo.AttachmentRequest{ maxigo.NewInlineKeyboardAttachment([][]maxigo.Button{ { maxigo.NewCallbackButton("Да", "yes"), maxigo.NewCallbackButton("Нет", "no"), }, { maxigo.NewCallbackButtonWithIntent("Отмена", "cancel", maxigo.IntentNegative), }, }), }, })
Для каждого типа кнопки — свой конструктор:
Callback — NewCallbackButton, NewCallbackButtonWithIntent (с цветом намерения)
Ссылки — NewLinkButton, NewOpenAppButton (mini-app)
Запросы данных — NewRequestContactButton, NewRequestGeoLocationButton
Действия — NewChatButton (создать чат), NewMessageButton (ответ от пользователя)

IDE подскажет, что есть. Не нужно помнить имена полей или лезть в документацию.
Пользователь нажал кнопку — бот получает MessageCallbackUpdate. Нюанс: в Max Bot API у callback-а нет поля ChatID напрямую. Приходится доставать из вложенного сообщения:
for _, update := range updates { if update.Type == maxigo.UpdateMessageCallback { cb := update.CallbackUpdate() chatID := cb.Message.Recipient.ChatID err := client.AnswerCallback(ctx, cb.Callback.CallbackID, &maxigo.CallbackAnswer{ Message: &maxigo.NewMessageBody{ Text: maxigo.Some(fmt.Sprintf("Вы выбрали: %s", cb.Callback.Payload)), }, }) } }
В официальном клиенте есть GetChatID(), но для callback-ов он возвращает 0. Здесь путь явный: cb.Message.Recipient.ChatID — никаких сюрпризов.
Фото, видео, аудио — всё через один паттерн:
f, _ := os.Open("photo.jpg") defer f.Close() photo, err := client.UploadPhoto(ctx, "photo.jpg", f) if err != nil { return fmt.Errorf("upload photo: %w", err) } _, err = client.SendMessage(ctx, chatID, &maxigo.NewMessageBody{ Text: maxigo.Some("Посмотри��е на это!"), Attachments: []maxigo.AttachmentRequest{ maxigo.NewPhotoAttachment(photo), }, })
Здесь тоже была своя «неожиданность». Локально всё работало, а на сервере — ошибка. Оказалось, Max отклоняет chunked transfer encoding. Нужен точный Content-Length в заголовке.
В официальном клиенте загрузка идёт через http.DefaultClient без контекста — ни таймаута, ни отмены. Здесь тело буферизуется, размер считается, context.Context работает как положено.
Помните про log.Println? Вот как это выглядит здесь:
updates, err := client.GetUpdates(ctx, maxigo.GetUpdatesOpts{Timeout: 30}) if err != nil { var e *maxigo.Error if errors.As(err, &e) { switch e.Kind { case maxigo.ErrTimeout: log.Printf("Таймаут в %s", e.Op) case maxigo.ErrAPI: log.Printf("API вернул %d: %s", e.StatusCode, e.Message) case maxigo.ErrNetwork: log.Printf("Сетевая ошибка: %v", e.Err) } } return err }
e.Op — имя операции, e.Kind — тип ошибки, e.Err — оригинал для errors.Unwrap(). Вы решаете, что делать. Не библиотека.
Помните maxigo.Some("текст")? Вот зачем это нужно.
В Go есть неприятная проблема: omitempty не различает «не указано» и «указано как пустое». Хотите отправить "notify": false — omitempty проглотит. Хотите очистить текст, отправив "text": "" — то же самое.
И да — помните историю с исчезающей клавиатурой? Пустой "attachments": [] вместо отсутствующего поля. Та же проблема.
Решение — generic Optional[T]:
type Optional[T any] struct { Value T Set bool } func Some[T any](v T) Optional[T] // указано func None[T any]() Optional[T] // не указано
Три состояния вместо двух:
Не указано → поле опущено в JSON
Some("") → отправляется ""
Some("текст") → отправляется "текст"
Никаких волшебных исчезновений кнопок. Поле не указали — его нет в запросе.
Одно сравнение, которое говорит больше любых слов.
Типичный метод в официальном клиенте:
func (a *messages) GetMessage(ctx context.Context, messageID string) (*schemes.Message, error) { result := new(schemes.Message) body, err := a.client.request(ctx, http.MethodGet, path, nil, false, nil) if err != nil { return result, err } defer func() { if err := body.Close(); err != nil { slog.Error("failed to close response body", "error", err) } }() return result, json.NewDecoder(body).Decode(result) }
То же самое в maxigo-client:
func (c *Client) GetMessageByID(ctx context.Context, messageID string) (*Message, error) { var result Message if err := c.do(ctx, "GetMessageByID", http.MethodGet, "/messages/"+messageID, nil, nil, &result); err != nil { return nil, err } return &result, nil }
Шесть строк. При ошибке — nil, err. Без ошибки — &result, nil. Никаких defer с логированием, никаких полупустых структур. Метод c.do() сам закрывает body и оборачивает ошибки в типизированный *Error.
Тот же принцип в каждом методе. Ошибка — всегда наверх.
Помните, с чего начиналось?
bot.Handle("/start", func(ctx bot.Context) error { return ctx.Reply("Привет!") })
maxigo-client — фундамент. Следующий шаг — фреймворк с роутером, middleware, контекстом. Всё как хотелось.
Что внутри: 38 методов API, все 16 типов Update, покрытие тестами 89%. Зависимостей — ноль, лицензия MIT.
GitHub: github.com/maxigo-bot/maxigo-client
pkg.go.dev: pkg.go.dev/github.com/maxigo-bot/1maxigo-client
Баги — в Issue, идеи — в PR или звезда — чтобы не потерять.
Первая статья на Хабре — буду рад обратной связи.
А вы уже пишете ботов для Max?
UPD: Фреймворк с роутером, middleware и контекстом уже готов — maxigo-bot. Тот самый bot.Handle("/start", ...) из начала статьи теперь работает.