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

Комментарии (174)


  1. empezamosdenuevodesdecero
    21.02.2026 05:57

    И как оно? Получать деньги за такое.


    1. yudinsv666 Автор
      21.02.2026 05:57

      Привет, это чисто техническая статья. Экосистема Max сейчас практически пустая — хотелось наполнить её качественным кодом, который пригодится людям. Max тут просто контекст, а не реклама.


      1. Volodrippa
        21.02.2026 05:57

        Так чем же обеспечено это "хотелось"?


        1. yudinsv666 Автор
          21.02.2026 05:57

          Привет, я вроде и не скрываю, что произошло это из-за замедления ТГ. И когда есть продукт, который начинает жестко замедляться, тут особо без вариантов. Многие накидали минусов, хотя я уже указывал, что это ни в коем случае не реклама. Но я уверен, что немалому числу разработчиков так же прийдется дублировать свои продукты в Max, и хотелось, чтобы им было проще перенести логику на фреймворк, похожий на telebot.

          P.S. Заранее извиняюсь если вызывал у кого-то горение) Хотел сделать как лучше, получилось...


          1. LynchTime
            21.02.2026 05:57

            Благодаря наполнению там все окажемся. "Спасибо", что двигаете в ту сторону.


            1. hochbar
              21.02.2026 05:57

              Понимаете, вы или я не можем воевать с ветром. Или надо поднимать паруса и плыть либо взять весла и грести против- естественно без результата


          1. petsernik
            21.02.2026 05:57

            И когда есть продукт, который начинает жестко замедляться, тут особо без вариантов.

            Вот вместо того, чтобы генерировать варианты, Вы решили поддержать блокировки своими действиями. Всякий, кто пишет софт под Max продвигает этот самый Max, само его упоминание в нейтральном или положительном ключе — уже реклама, с учётом того насколько насильно его продвигают. И уж тем более реклама, когда у Вас возникают размышления в духе "все там будем", как-то легко Вы сдались и подчинились, за это и минусы.

            Помощь разработчикам писать под Max ускоряет процесс всеобщего перехода на него, "спасибо" Вам за труд.


            1. venanen
              21.02.2026 05:57

              Помощь разработчикам писать под Max ускоряет процесс всеобщего перехода на него, "спасибо" Вам за труд.

              Это если разработчикам и государству было не пофиг на людей. А тут по другому - мы в любом случае там окажемся, если вайтлисты сделают. И хорошо, если к этому моменту достаточно отреверсят, сделают ботов, е2е-шифрование и т.д.


              1. ddr5
                21.02.2026 05:57

                Любой уважающий себя айтишник должен собрать чемодан и уехать, если сделают вайтлисты.

                e2e-шифрования в максе никогда не будет, он не для того делался, чтобы переписку нельзя было прочитать.


                1. Hacker_in_Sunset
                  21.02.2026 05:57

                  Если каждый «уважающий себя айтишник» будет собирать чемодан из-за каждого ужесточения или спорной новости, то IT-сфера превратится в бесконечный флешмоб с чемоданами на колесиках. Где-то нужно и работать. Про «никогда не будет»: Вы, видимо, видели исходники Макса и точно знаете дорожную карту на 10 лет вперед? С таким подходом мы бы до сих пор сидели на dial-up и молились на IE6. Человек просто улучшил функцию. Он не обещал вам e2e. Он просто сделал свою работу/хобби чуть лучше. Может, стоит попробовать? А не драматизировать.


                  1. ddr5
                    21.02.2026 05:57

                    Что пробовать? У меня достаточно самоуважения, чтобы отказаться пользоваться тем, что насильно забивают сапогом в глотку.


                    1. Hacker_in_Sunset
                      21.02.2026 05:57

                      Вы сейчас говорите о вендор-локе или о политике импортозамещения? Если о принуждении к использованию конкретного инструмента, то статья как раз и посвящена тому, как улучшить опыт добровольного использования среды разработки. Апдейты в Максе - это не «забивание в глотку», а изменение условий контракта-лицензии, которые Вы вольны принять или не принять, перейдя на альтернативы. Если же Вы о чем-то другом, то, кажется, Вы ищете политический подтекст там, где инженеры обсуждают снижение цикломатической сложности. При чем здесь «самоуважение», если мы говорим о выборе инструмента под задачу? Это статья Вас как то оскорбляет, принижает? Человек сделал то, что ему захотелось и выложил статью. Не нужно работу/хоббу соединять с политикой. Никто тут не заставляет пользоваться Вас Максом.


                      1. logran
                        21.02.2026 05:57

                        Вам пытаются внести Флюгегехаймен, а вы вместо того чтобы защищать идею противиться самому факту вноса, или идею поискать/разработать ИНЫЕ альтернативы - поддерживаете идею разработки смазки, чтобы легче входило.

                        Макс - это буквально и есть политика, которую пихают вам в глотку. И все попытки его как-то совершенствоать - это давать им фиксаторы, чтобы рот не закрылся пока пихают.

                        Это инструмент контроля и цензуры, а не импортозамещения. Тот же e2e идеологически не совместим целями существования и внедрения данного продукта, и никая дорожная карта на 10 лет тут не поможет. Этих функций там специально нет потому что именно они и мешали в телеграме/вотсапе/etc.

                        Ну и да, блокировка интернета, завертывание в интранет и вайтлисты - это будет уже не "ужесточения или спорные новости", это будет тотальный звиздец и смерть половины отрасли. И перед подобным действительно целесообразно собрать чемодан и завести трактор, ибо следующим шагом обычно идет железный занавес.


                      1. ddr5
                        21.02.2026 05:57

                        Насчет железного занавеса спорно все же, но в остальном полностью согласен, нечего добавить.


                      1. Vlad__Ok
                        21.02.2026 05:57

                        Когда принимали законн о косячном российском фаерволле вы наверное тоже надеялись ... как там в фильме "когда душили его жену он ей говорил - потерпи родная может все обойдется" :))?


                      1. Liprekon
                        21.02.2026 05:57

                        Насчет железного занавеса спорно ... А то что он надвигается и просвет всё меньше и меньше остаётся? Нет возможности нормально позвонить за границу без всяких танцев с бубном, нет возможности искать информацию которая необходима МНЕ, а не не то что подсовывает государство. И это и есть самый натуральный железный занавес, дальше будет только хуже...


                      1. ddr5
                        21.02.2026 05:57

                        Спорно то, что окончательно выезд закроют. Я железный занавес именно таким вижу.


                      1. Hacker_in_Sunset
                        21.02.2026 05:57

                        В чём-то Вы правы, даже спорить не буду. Всё равно приходится и придётся работать с тем, что уже есть и наши «голоса» ЗА и ПРОТИВ - будут вертеть) Всё равно кто-то и будет пользоваться приложением, кто бы что не говорил.


                      1. JM777
                        21.02.2026 05:57

                        У вас опять реклама смазки получается


                      1. JM777
                        21.02.2026 05:57

                        Неистово ржу и аплодирую) Карма у меня хреновая, поэтому даже плюсануть не могу.


                      1. Miller777
                        21.02.2026 05:57

                        Ну, совершенно добровольно научиться получать удовольствие от изнасилования, и сделать его более для себя комфортным, - такой метод тоже имеет право на жизнь, безусловно.

                        Я без осуждения, если что.

                        Я бы это длинной палкой не трогал, пока совсем не прижали.


                    1. Forigen
                      21.02.2026 05:57

                      Если что-то забивают в глотку сапогом, то права на отказ не подразумевается. Так в чем тогда выражается самоуважение? Мне видится обычное позерство ребяческое. Когда мама просит одеть шапку, а ты назло идёшь без шапки и расстёгнутый, просто потому что можешь)


                      1. d3d11
                        21.02.2026 05:57

                        Мама просит?


                      1. grvelvet
                        21.02.2026 05:57

                        консерва распаковалась, охранота работает.


                  1. Forigen
                    21.02.2026 05:57

                    не сажай цветы в свинарнике


                  1. MaratDobrolubov
                    21.02.2026 05:57

                    А какого будет разработчикам без GitHub, всех топовых нейросетей, да хоть тот же stackoverflow (земля ему пухом) а так же тысячи разных репозиториев начиная от линуха заканчивая питоном? Мне кажется в таких условиях для айтишника будет 2 дороги, либо в шарашку либо за бугор.


                1. Geth
                  21.02.2026 05:57

                  Любой уважающий себя айтишник должен собрать чемодан и уехать, если сделают вайтлисты.

                  Айтишники разные бывают и не у всех востребованные в других странах скиллы (особенно, если айтишник - не разработчик, а, например, админ откуда-нибудь из провинции).

                  Лучше уж с чебурнетом тут, чем бомжевать под мостом в другой стране, но со свободным интернетом.