Go HTTP client for Max Bot API. Zero external dependencies.
The official max-bot-api-client-go has systemic issues that make it unsuitable for production:
Errors are logged instead of returned — 30+ places use log.Println or slog.Error in library code. Users cannot control or suppress this output. Some errors are silently swallowed (json.Decode failure returns nil).
Cannot test without real API — no simple WithBaseURL() option. Upload methods use http.DefaultClient directly, bypassing all client configuration. Testing requires implementing a 7-method ConfigInterface.
6 external dependencies — zerolog, yaml parser, env parser, gomock — all unnecessary for an HTTP client.
Broken methods — GetChatID() returns 0 for callbacks (chat ID is available but ignored). GetCommand() returns the full message text. schemes.Error used as response struct is always non-nil, so Check() always returns an error.
Wrong types — time.Duration for Unix timestamps (interprets as nanoseconds). int64→int casts in 10+ places (truncates on 32-bit). []interface{} for attachments (no type safety).
Non-idiomatic Go — builder pattern, SCREAMING_CASE constants, Api instead of API, no context.Context in uploads, no functional options.
| Problem | Official client | maxigo-client |
|---|---|---|
| Error handling | log.Println in 30+ places |
All errors returned as *Error with Kind/StatusCode/Op |
| Testability | Need full ConfigInterface mock |
maxigo.New("token", WithBaseURL(srv.URL)) |
| Dependencies | 6 transitive (zerolog, yaml, env...) | 0 — only stdlib |
GetChatID() for callbacks |
Returns 0 | Extract from Message.Recipient.ChatId |
| Types | time.Duration for timestamps, int→int64 casts |
Correct int64 everywhere |
| Uploads | http.Get() without context/timeout |
All requests through configured client with context.Context |
| API style | NewMessage().SetChat().SetText() |
SendMessage(ctx, chatID, &NewMessageBody{Text: Some("text")}) |
| Constants | TYPING_ON, CALLBACK, POSITIVE |
ActionTypingOn, IntentPositive |
| Configuration | YAML files + env parser | Functional options: WithTimeout, WithHTTPClient |
| Edit message attachments | No omitempty — [] always sent, silently deletes attachments on edit |
omitzero — nil = keep, [] = delete, correct semantics |
Optional fields (bool, string) |
bool + omitempty — can't send false/"" |
Optional[T] generics — three states: unset / zero / value |
maxigo-client fixes all of these.
go get github.com/maxigo-bot/maxigo-clientRequires Go 1.25+.
package main
import (
"context"
"fmt"
"log"
maxigo "github.com/maxigo-bot/maxigo-client"
)
func main() {
client, err := maxigo.New("YOUR_BOT_TOKEN")
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
// Get bot info
bot, err := client.GetBot(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Bot: %s (ID: %d)\n", bot.FirstName, bot.UserID)
// Send a message
msg, err := client.SendMessage(ctx, 123456, &maxigo.NewMessageBody{
Text: maxigo.Some("Hello from maxigo!"),
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Sent message: %s\n", msg.Body.MID)
}The client is configured using functional options:
client, err := maxigo.New("token",
maxigo.WithTimeout(10 * time.Second), // HTTP request timeout (default: 30s)
maxigo.WithHTTPClient(customHTTPClient), // custom *http.Client
maxigo.WithBaseURL("https://custom.api"), // custom base URL
)WithBaseURL is useful for testing — point the client at an httptest.Server.
// To a chat
msg, err := client.SendMessage(ctx, chatID, &maxigo.NewMessageBody{
Text: maxigo.Some("Hello!"),
})
// To a specific user
msg, err := client.SendMessageToUser(ctx, userID, &maxigo.NewMessageBody{
Text: maxigo.Some("Direct message"),
})
// With formatting
msg, err := client.SendMessage(ctx, chatID, &maxigo.NewMessageBody{
Text: maxigo.Some("**Bold** and _italic_"),
Format: maxigo.Some(maxigo.FormatMarkdown),
})
// With inline keyboard using button constructors
msg, err := client.SendMessage(ctx, chatID, &maxigo.NewMessageBody{
Text: maxigo.Some("Choose an action:"),
Attachments: []maxigo.AttachmentRequest{
maxigo.NewInlineKeyboardAttachment([][]maxigo.Button{
{
maxigo.NewCallbackButtonWithIntent("Yes", "yes", maxigo.IntentPositive),
maxigo.NewCallbackButtonWithIntent("No", "no", maxigo.IntentNegative),
},
}),
},
})
// With link button
msg, err := client.SendMessage(ctx, chatID, &maxigo.NewMessageBody{
Text: maxigo.Some("Visit our website:"),
Attachments: []maxigo.AttachmentRequest{
maxigo.NewInlineKeyboardAttachment([][]maxigo.Button{
{maxigo.NewLinkButton("Open", "https://example.com")},
}),
},
})
// Reply to a message
msg, err := client.SendMessage(ctx, chatID, &maxigo.NewMessageBody{
Text: maxigo.Some("This is a reply"),
Link: &maxigo.NewMessageLink{
Type: maxigo.LinkReply,
MID: "mid-original",
},
})
// Forward a message
msg, err := client.SendMessage(ctx, chatID, &maxigo.NewMessageBody{
Link: &maxigo.NewMessageLink{
Type: maxigo.LinkForward,
MID: "mid-to-forward",
},
})The library provides type-safe constructors for all button types, so you don't have to remember string constants:
// Callback button — sends payload to bot via webhook/polling
maxigo.NewCallbackButton("Click me", "payload")
maxigo.NewCallbackButtonWithIntent("Confirm", "yes", maxigo.IntentPositive)
// Link button — opens a URL
maxigo.NewLinkButton("Open site", "https://example.com")
// Request contact — asks user to share their contact info
maxigo.NewRequestContactButton("Share contact")
// Request geo location — asks user to share their location
// quick=true sends location without confirmation dialog
maxigo.NewRequestGeoLocationButton("Send location", false)
// Chat button — creates a new chat with the bot as admin
maxigo.NewChatButton("Create chat", "Chat Title")
// Message button — button text is sent as a message from the user in chat
maxigo.NewMessageButton("Book appointment")
// Open app button — opens a mini-app inside the messenger
maxigo.NewOpenAppButton("Open WebApp", "bot_username")Example — request contact button in inline keyboard:
msg, err := client.SendMessage(ctx, chatID, &maxigo.NewMessageBody{
Text: maxigo.Some("Please share your contact:"),
Attachments: []maxigo.AttachmentRequest{
maxigo.NewInlineKeyboardAttachment([][]maxigo.Button{
{maxigo.NewRequestContactButton("Share contact")},
}),
},
})// Edit a message
result, err := client.EditMessage(ctx, "mid-123", &maxigo.NewMessageBody{
Text: maxigo.Some("Updated text"),
})
// Delete a message
result, err := client.DeleteMessage(ctx, "mid-123")// List messages from a chat
messages, err := client.GetMessages(ctx, maxigo.GetMessagesOpts{ChatID: chatID, Count: 50})
// Get a specific message by ID
msg, err := client.GetMessageByID(ctx, "mid-123")When a user presses an inline button:
result, err := client.AnswerCallback(ctx, callbackID, &maxigo.CallbackAnswer{
Notification: maxigo.Some("Done!"),
})
// Or replace the message:
result, err := client.AnswerCallback(ctx, callbackID, &maxigo.CallbackAnswer{
Message: &maxigo.NewMessageBody{
Text: maxigo.Some("Button was pressed!"),
},
})// Get a chat
chat, err := client.GetChat(ctx, chatID)
// Get a chat by invite link
chat, err := client.GetChatByLink(ctx, "https://max.ru/join/abc123")
// List chats (paginated)
list, err := client.GetChats(ctx, maxigo.GetChatsOpts{Count: 50})
// Next page:
list2, err := client.GetChats(ctx, maxigo.GetChatsOpts{Count: 50, Marker: *list.Marker})
// Edit a chat
chat, err := client.EditChat(ctx, chatID, &maxigo.ChatPatch{
Title: maxigo.Some("New Title"),
})
// Delete a chat
result, err := client.DeleteChat(ctx, chatID)
// Members
members, err := client.GetMembers(ctx, chatID, maxigo.GetMembersOpts{Count: 100})
admins, err := client.GetAdmins(ctx, chatID)
// Add / remove members
result, err := client.AddMembers(ctx, chatID, []int64{userID1, userID2})
result, err := client.RemoveMember(ctx, chatID, userID, false) // block=false
// Send typing action
result, err := client.SendAction(ctx, chatID, maxigo.ActionTypingOn)
// Pin / unpin messages
result, err := client.PinMessage(ctx, chatID, &maxigo.PinMessageBody{MessageID: "mid-1"})
result, err := client.UnpinMessage(ctx, chatID)
pinned, err := client.GetPinnedMessage(ctx, chatID)
// Bot's own membership
membership, err := client.GetMembership(ctx, chatID)
// Leave a chat
result, err := client.LeaveChat(ctx, chatID)| Constant | Description |
|---|---|
ActionTypingOn |
Bot is typing |
ActionSendPhoto |
Bot is sending a photo |
ActionSendVideo |
Bot is sending a video |
ActionSendAudio |
Bot is sending audio |
ActionSendFile |
Bot is sending a file |
ActionMarkSeen |
Mark messages as read |
Messages from the API contain attachments as []json.RawMessage. Use ParseAttachments() to convert them into typed structs:
attachments, err := msg.Body.ParseAttachments()
if err != nil {
log.Fatal(err)
}
for _, att := range attachments {
switch a := att.(type) {
case *maxigo.PhotoAttachment:
fmt.Println("Photo URL:", a.Payload.URL)
case *maxigo.ContactAttachment:
if a.Payload.MaxInfo != nil {
fmt.Println("Contact:", a.Payload.MaxInfo.FirstName)
}
case *maxigo.LocationAttachment:
fmt.Printf("Location: %f, %f\n", a.Latitude, a.Longitude)
case *maxigo.InlineKeyboardAttachment:
fmt.Println("Keyboard buttons:", len(a.Payload.Buttons))
}
}All 11 attachment types are supported:
JSON type |
Go struct |
|---|---|
image |
*PhotoAttachment |
video |
*VideoAttachment |
audio |
*AudioAttachment |
file |
*FileAttachment |
sticker |
*StickerAttachment |
contact |
*ContactAttachment |
share |
*ShareAttachment |
location |
*LocationAttachment |
data |
*DataAttachment |
inline_keyboard |
*InlineKeyboardAttachment |
reply_keyboard |
*ReplyKeyboardAttachment |
Unknown types are silently skipped for forward compatibility.
Uploading is a two-step process: get an upload URL, then upload the file.
// Photo (simplified)
file, _ := os.Open("photo.jpg")
tokens, err := client.UploadPhoto(ctx, "photo.jpg", file)
// Then send with the token:
client.SendMessage(ctx, chatID, &maxigo.NewMessageBody{
Attachments: []maxigo.AttachmentRequest{
{
Type: "image",
Payload: maxigo.PhotoAttachmentRequestPayload{
Photos: tokens.Photos,
},
},
},
})
// Video / audio / file
file, _ := os.Open("video.mp4")
info, err := client.UploadMedia(ctx, maxigo.UploadVideo, "video.mp4", file)
// Manual two-step (if you need more control):
endpoint, err := client.GetUploadURL(ctx, maxigo.UploadFile)
// Then POST the file to endpoint.URL| Constant | Description |
|---|---|
UploadImage |
Image files (jpg, png, gif) |
UploadVideo |
Video files |
UploadAudio |
Audio files |
UploadFile |
Any file |
// Subscribe to updates
result, err := client.Subscribe(ctx,
"https://example.com/webhook",
[]string{"message_created", "message_callback"},
"my-secret",
)
// Unsubscribe
result, err := client.Unsubscribe(ctx, "https://example.com/webhook")
// List active subscriptions
subs, err := client.GetSubscriptions(ctx)
for _, s := range subs {
fmt.Printf("Webhook: %s, types: %v\n", s.URL, s.UpdateTypes)
}var marker int64
for {
result, err := client.GetUpdates(ctx, maxigo.GetUpdatesOpts{Limit: 100, Timeout: 30, Marker: marker})
if err != nil {
log.Println("error:", err)
time.Sleep(time.Second)
continue
}
for _, raw := range result.Updates {
var base maxigo.Update
json.Unmarshal(raw, &base)
switch base.UpdateType {
case maxigo.UpdateMessageCreated:
var upd maxigo.MessageCreatedUpdate
json.Unmarshal(raw, &upd)
fmt.Println("New message:", *upd.Message.Body.Text)
case maxigo.UpdateMessageCallback:
var upd maxigo.MessageCallbackUpdate
json.Unmarshal(raw, &upd)
fmt.Println("Callback:", upd.Callback.Payload)
case maxigo.UpdateBotStarted:
var upd maxigo.BotStartedUpdate
json.Unmarshal(raw, &upd)
fmt.Printf("User %d pressed Start\n", upd.User.UserID)
case maxigo.UpdateBotAdded:
var upd maxigo.BotAddedUpdate
json.Unmarshal(raw, &upd)
fmt.Printf("Bot added to chat %d\n", upd.ChatID)
case maxigo.UpdateUserAdded:
var upd maxigo.UserAddedUpdate
json.Unmarshal(raw, &upd)
fmt.Printf("User %d added to chat %d\n", upd.User.UserID, upd.ChatID)
}
}
if result.Marker != nil {
marker = *result.Marker
}
}| Constant | Struct | Description |
|---|---|---|
UpdateMessageCreated |
MessageCreatedUpdate |
New message |
UpdateMessageCallback |
MessageCallbackUpdate |
Inline button pressed |
UpdateMessageEdited |
MessageEditedUpdate |
Message edited |
UpdateMessageRemoved |
MessageRemovedUpdate |
Message deleted |
UpdateBotStarted |
BotStartedUpdate |
User pressed Start |
UpdateBotAdded |
BotAddedUpdate |
Bot added to chat |
UpdateBotRemoved |
BotRemovedUpdate |
Bot removed from chat |
UpdateUserAdded |
UserAddedUpdate |
User added to chat |
UpdateUserRemoved |
UserRemovedUpdate |
User removed from chat |
UpdateChatTitleChanged |
ChatTitleChangedUpdate |
Chat title changed |
UpdateMessageChatCreated |
MessageChatCreatedUpdate |
Chat created via button |
UpdateBotStopped |
BotStoppedUpdate |
User stopped the bot |
UpdateDialogMuted |
DialogMutedUpdate |
User muted dialog |
UpdateDialogUnmuted |
DialogUnmutedUpdate |
User unmuted dialog |
UpdateDialogCleared |
DialogClearedUpdate |
User cleared dialog |
UpdateDialogRemoved |
DialogRemovedUpdate |
User removed dialog |
All errors are returned as *maxigo.Error with structured fields:
msg, err := client.SendMessage(ctx, chatID, body)
if err != nil {
var e *maxigo.Error
if errors.As(err, &e) {
switch e.Kind {
case maxigo.ErrAPI:
// API returned non-200: e.StatusCode (401, 403, 404, 429, 500...)
fmt.Printf("API error %d: %s\n", e.StatusCode, e.Message)
case maxigo.ErrNetwork:
// Connection or DNS failure
fmt.Println("Network:", e.Message)
case maxigo.ErrTimeout:
// Request timeout or context cancellation
fmt.Println("Timeout")
case maxigo.ErrDecode:
// JSON marshal/unmarshal failure
fmt.Println("Decode error:", e.Message)
}
// e.Op — operation name ("SendMessage", "GetChat", ...)
// e.Err — underlying error (for Unwrap)
}
}| Kind | Description |
|---|---|
ErrAPI |
HTTP response with status != 200 |
ErrNetwork |
Connection, DNS, or transport failure |
ErrTimeout |
Request timeout or context cancellation |
ErrDecode |
JSON marshal/unmarshal failure |
e.Error() string— formatted error message including Op, Kind, StatusCodee.Timeout() bool— returnstrueforErrTimeoute.Unwrap() error— returns the underlying error forerrors.Is/Aschains
var e *maxigo.Error
if errors.As(err, &e) && e.Kind == maxigo.ErrAPI {
switch e.StatusCode {
case 401:
// Invalid token
case 403:
// No permission
case 404:
// Chat/message not found
case 429:
// Rate limited — back off and retry
}
}- Commands use
:as separator (not space like Telegram):/start:payload. No@botname. MessageCallbackUpdatehas no direct ChatID — extract fromMessage.Recipient.ChatId.
The client is fully testable without hitting the real API:
func TestMyBot(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(maxigo.BotInfo{
UserWithPhoto: maxigo.UserWithPhoto{
User: maxigo.User{UserID: 1, FirstName: "TestBot", IsBot: true},
},
})
}))
defer srv.Close()
client, _ := maxigo.New("test-token", maxigo.WithBaseURL(srv.URL))
bot, err := client.GetBot(context.Background())
// assert...
}| Package | Description |
|---|---|
| maxigo-client | Idiomatic Go HTTP client for Max Bot API (zero external deps) |
| maxigo-bot | Bot framework with router, middleware, and context |
MIT