
Если у вас есть бот в Телеграме, то наверняка уже поглядываете в сторону 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 подскажет, что есть. Не нужно помнить имена полей или лезть в документацию.
Обработка 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?
UPD: Фреймворк с роутером, middleware и контекстом уже готов — maxigo-bot. Тот самый bot.Handle("/start", ...) из начала статьи теперь работает.
empezamosdenuevodesdecero
И как оно? Получать деньги за такое.
yudinsv666 Автор
Привет, это чисто техническая статья. Экосистема Max сейчас практически пустая — хотелось наполнить её качественным кодом, который пригодится людям. Max тут просто контекст, а не реклама.
Volodrippa
Так чем же обеспечено это "хотелось"?
yudinsv666 Автор
Привет, я вроде и не скрываю, что произошло это из-за замедления ТГ. И когда есть продукт, который начинает жестко замедляться, тут особо без вариантов. Многие накидали минусов, хотя я уже указывал, что это ни в коем случае не реклама. Но я уверен, что немалому числу разработчиков так же прийдется дублировать свои продукты в Max, и хотелось, чтобы им было проще перенести логику на фреймворк, похожий на telebot.
P.S. Заранее извиняюсь если вызывал у кого-то горение) Хотел сделать как лучше, получилось...
LynchTime
Благодаря наполнению там все окажемся. "Спасибо", что двигаете в ту сторону.
hochbar
Понимаете, вы или я не можем воевать с ветром. Или надо поднимать паруса и плыть либо взять весла и грести против- естественно без результата
petsernik
Вот вместо того, чтобы генерировать варианты, Вы решили поддержать блокировки своими действиями. Всякий, кто пишет софт под Max продвигает этот самый Max, само его упоминание в нейтральном или положительном ключе — уже реклама, с учётом того насколько насильно его продвигают. И уж тем более реклама, когда у Вас возникают размышления в духе "все там будем", как-то легко Вы сдались и подчинились, за это и минусы.
Помощь разработчикам писать под Max ускоряет процесс всеобщего перехода на него, "спасибо" Вам за труд.
venanen
Это если разработчикам и государству было не пофиг на людей. А тут по другому - мы в любом случае там окажемся, если вайтлисты сделают. И хорошо, если к этому моменту достаточно отреверсят, сделают ботов, е2е-шифрование и т.д.
ddr5
Любой уважающий себя айтишник должен собрать чемодан и уехать, если сделают вайтлисты.
e2e-шифрования в максе никогда не будет, он не для того делался, чтобы переписку нельзя было прочитать.
Hacker_in_Sunset
Если каждый «уважающий себя айтишник» будет собирать чемодан из-за каждого ужесточения или спорной новости, то IT-сфера превратится в бесконечный флешмоб с чемоданами на колесиках. Где-то нужно и работать. Про «никогда не будет»: Вы, видимо, видели исходники Макса и точно знаете дорожную карту на 10 лет вперед? С таким подходом мы бы до сих пор сидели на dial-up и молились на IE6. Человек просто улучшил функцию. Он не обещал вам e2e. Он просто сделал свою работу/хобби чуть лучше. Может, стоит попробовать? А не драматизировать.
ddr5
Что пробовать? У меня достаточно самоуважения, чтобы отказаться пользоваться тем, что насильно забивают сапогом в глотку.
Hacker_in_Sunset
Вы сейчас говорите о вендор-локе или о политике импортозамещения? Если о принуждении к использованию конкретного инструмента, то статья как раз и посвящена тому, как улучшить опыт добровольного использования среды разработки. Апдейты в Максе - это не «забивание в глотку», а изменение условий контракта-лицензии, которые Вы вольны принять или не принять, перейдя на альтернативы. Если же Вы о чем-то другом, то, кажется, Вы ищете политический подтекст там, где инженеры обсуждают снижение цикломатической сложности. При чем здесь «самоуважение», если мы говорим о выборе инструмента под задачу? Это статья Вас как то оскорбляет, принижает? Человек сделал то, что ему захотелось и выложил статью. Не нужно работу/хоббу соединять с политикой. Никто тут не заставляет пользоваться Вас Максом.
logran
Вам пытаются внести Флюгегехаймен, а вы вместо того чтобы защищать идею противиться самому факту вноса, или идею поискать/разработать ИНЫЕ альтернативы - поддерживаете идею разработки смазки, чтобы легче входило.
Макс - это буквально и есть политика, которую пихают вам в глотку. И все попытки его как-то совершенствоать - это давать им фиксаторы, чтобы рот не закрылся пока пихают.
Это инструмент контроля и цензуры, а не импортозамещения. Тот же e2e идеологически не совместим целями существования и внедрения данного продукта, и никая дорожная карта на 10 лет тут не поможет. Этих функций там специально нет потому что именно они и мешали в телеграме/вотсапе/etc.
Ну и да, блокировка интернета, завертывание в интранет и вайтлисты - это будет уже не "ужесточения или спорные новости", это будет тотальный звиздец и смерть половины отрасли. И перед подобным действительно целесообразно собрать чемодан и завести трактор, ибо следующим шагом обычно идет железный занавес.
ddr5
Насчет железного занавеса спорно все же, но в остальном полностью согласен, нечего добавить.
Vlad__Ok
Когда принимали законн о косячном российском фаерволле вы наверное тоже надеялись ... как там в фильме "когда душили его жену он ей говорил - потерпи родная может все обойдется" :))?
Liprekon
Насчет железного занавеса спорно ... А то что он надвигается и просвет всё меньше и меньше остаётся? Нет возможности нормально позвонить за границу без всяких танцев с бубном, нет возможности искать информацию которая необходима МНЕ, а не не то что подсовывает государство. И это и есть самый натуральный железный занавес, дальше будет только хуже...
ddr5
Спорно то, что окончательно выезд закроют. Я железный занавес именно таким вижу.
Hacker_in_Sunset
В чём-то Вы правы, даже спорить не буду. Всё равно приходится и придётся работать с тем, что уже есть и наши «голоса» ЗА и ПРОТИВ - будут вертеть) Всё равно кто-то и будет пользоваться приложением, кто бы что не говорил.
JM777
У вас опять реклама смазки получается
JM777
Неистово ржу и аплодирую) Карма у меня хреновая, поэтому даже плюсануть не могу.
Miller777
Ну, совершенно добровольно научиться получать удовольствие от изнасилования, и сделать его более для себя комфортным, - такой метод тоже имеет право на жизнь, безусловно.
Я без осуждения, если что.
Я бы это длинной палкой не трогал, пока совсем не прижали.
Forigen
Если что-то забивают в глотку сапогом, то права на отказ не подразумевается. Так в чем тогда выражается самоуважение? Мне видится обычное позерство ребяческое. Когда мама просит одеть шапку, а ты назло идёшь без шапки и расстёгнутый, просто потому что можешь)
d3d11
Мама просит?
grvelvet
консерва распаковалась, охранота работает.
Forigen
не сажай цветы в свинарнике
MaratDobrolubov
А какого будет разработчикам без GitHub, всех топовых нейросетей, да хоть тот же stackoverflow (земля ему пухом) а так же тысячи разных репозиториев начиная от линуха заканчивая питоном? Мне кажется в таких условиях для айтишника будет 2 дороги, либо в шарашку либо за бугор.
Geth
Айтишники разные бывают и не у всех востребованные в других странах скиллы (особенно, если айтишник - не разработчик, а, например, админ откуда-нибудь из провинции).
Лучше уж с чебурнетом тут, чем бомжевать под мостом в другой стране, но со свободным интернетом.