
Если у вас есть бот в Телеграме, то наверняка уже поглядываете в сторону 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("Отправлено: %sn", 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 подскажет, что есть. Не нужно помнить имена полей или лезть в документацию.
Обработка callback-ов
Пользователь нажал кнопку — бот получает 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(). Вы решаете, что делать. Не библиотека.
Optional[T]
Помните 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?
Автор: yudinsv666
