Если у вас есть бот в Телеграме, то наверняка уже поглядываете в сторону 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),
            },
        }),
    },
})

Для каждого типа кнопки — свой конструктор:

  • CallbackNewCallbackButton, 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": falseomitempty проглотит. Хотите очистить текст, отправив "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", ...) из начала статьи теперь работает.