- PVSM.RU - https://www.pvsm.ru -

Если у вас есть бот в Телеграме, то наверняка уже поглядываете в сторону 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 подскажет, что есть. Не нужно помнить имена полей или лезть в документацию.
Пользователь нажал кнопку — бот получает 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 [2]
pkg.go.dev: pkg.go.dev/github.com/maxigo-bot/1maxigo-client [3]
Баги — в Issue, идеи — в PR или звезда — чтобы не потерять.
Первая статья на Хабре — буду рад обратной связи.
А вы уже пишете ботов для Max?
Автор: yudinsv666
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/bot/445313
Ссылки в тексте:
[1] Image: https://sourcecraft.dev/
[2] github.com/maxigo-bot/maxigo-client: https://github.com/maxigo-bot/maxigo-client
[3] pkg.go.dev/github.com/maxigo-bot/1maxigo-client: https://pkg.go.dev/github.com/maxigo-bot/maxigo-client
[4] Источник: https://habr.com/ru/articles/1002098/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1002098
Нажмите здесь для печати.