max.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"slices"
"strconv"
"strings"
"time"
maxbot "github.com/max-messenger/max-bot-api-client-go/v2"
"github.com/max-messenger/max-bot-api-client-go/v2/model"
)
// attachment is a file already uploaded to MAX.
type attachment struct {
kind model.AttachmentType
token string
sourceID string // stable source file ID, never the temporary upload token
}
func isNumber(s string) bool {
_, err := strconv.ParseInt(s, 10, 64)
return err == nil
}
// maxChat is a MAX channel the bot posts to.
type maxChat struct {
id int64
title string
kind model.ChatType
}
type maxClient struct {
api *maxbot.Api
http *http.Client
publish *http.Client
baseURL string
token string
}
// russianRootCA is the Russian Trusted Root CA of the Ministry of Digital
// Development (SHA-256 D2:6D:2D:02:…:CA:8E:CF:31, valid until 2032). Since
// July 2026 the MAX API at platform-api2.max.ru presents a certificate issued
// under it, and no standard trust store includes it.
//
//go:embed certs/russian_trusted_root_ca.pem
var russianRootCA []byte
// maxTransport trusts the system roots plus the Russian root. Only MAX
// requests use it: Telegram and everything else keep the system roots alone.
func maxTransport() (*http.Transport, error) {
roots, err := x509.SystemCertPool()
if err != nil {
roots = x509.NewCertPool()
}
if !roots.AppendCertsFromPEM(russianRootCA) {
return nil, errors.New("не удалось прочитать встроенный корневой сертификат Минцифры")
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}
return transport, nil
}
func maxMediaClient() (*http.Client, error) {
transport, err := maxTransport()
if err != nil {
return nil, err
}
// Attachment URLs come from posts. Resolve and connect to a public
// address ourselves, including after redirects.
transport.Proxy = nil
transport.DialContext = dialPublicMedia
return &http.Client{Transport: transport, Timeout: 10 * time.Minute, CheckRedirect: func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" || req.URL.User != nil || len(via) >= 5 {
return http.ErrUseLastResponse
}
return nil
}}, nil
}
var sharedIPv4 = netip.MustParsePrefix("100.64.0.0/10")
func dialPublicMedia(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, err
}
dialer := net.Dialer{Timeout: 4 * time.Second}
var dialErr error
for _, ip := range ips {
ip = ip.Unmap()
if !ip.IsGlobalUnicast() || ip.IsPrivate() || sharedIPv4.Contains(ip) {
continue
}
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
dialErr = err
}
if dialErr != nil {
return nil, fmt.Errorf("не удалось подключиться к публичному адресу вложения MAX: %w", dialErr)
}
return nil, errors.New("у вложения MAX нет публичного адреса")
}
func newMaxClient(token string) (*maxClient, error) {
transport, err := maxTransport()
if err != nil {
return nil, err
}
limited := pacedTransport(transport, 500*time.Millisecond)
client := &http.Client{
// The library default of 30 s is far too short for a video of up to 2 GB
Timeout: 30 * time.Minute,
Transport: retryableStatus{next: limited},
}
api, err := maxbot.NewApi(token, maxbot.WithHTTPClient(client))
if err != nil {
return nil, err
}
return &maxClient{
api: api, http: client, token: token, baseURL: "https://" + maxbot.DefaultHostV2,
publish: &http.Client{Transport: limited, Timeout: 90 * time.Second, CheckRedirect: noRedirect},
}, nil
}
// retryableStatus turns 429 and 5xx responses into transport errors: the MAX
// client retries network errors only and treats any status as final.
type retryableStatus struct {
next http.RoundTripper
}
func (t retryableStatus) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.next.RoundTrip(req)
if err == nil && (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500) {
_ = resp.Body.Close()
return nil, fmt.Errorf("MAX ответил %s", resp.Status)
}
return resp, err
}
// resolve finds a channel by link (https://max.ru/name), name or id.
// The caller checks read or write permissions according to the route.
func (m *maxClient) resolve(ctx context.Context, ref string) (maxChat, error) {
var (
chat model.Chat
err error
)
if id, parseErr := strconv.ParseInt(ref, 10, 64); parseErr == nil {
chat, err = m.api.Chats.GetChat(ctx, id)
} else {
err = m.get(ctx, "/chats/"+url.PathEscape(channelName(ref, "max.ru")), &chat)
}
if err != nil {
hint := "по ссылке MAX находит не все каналы — надёжнее числовой id: он в адресной строке, если открыть канал на web.max.ru"
if strings.HasPrefix(ref, "-") || !isNumber(ref) {
hint += "; и проверьте, что бот — администратор канала"
} else {
hint = "у каналов MAX id отрицательный — попробуйте -" + ref
}
return maxChat{}, fmt.Errorf("канал MAX %q не найден (%w): %s", ref, err, hint)
}
c := maxChat{id: chat.ChatID, title: chat.Title, kind: chat.Type}
if c.kind != model.ChatTypeChannel {
return maxChat{}, fmt.Errorf("«%s» — не канал MAX", c.title)
}
return c, nil
}
// checkAdmin checks that the bot can still post to the channel.
func (m *maxClient) checkAdmin(ctx context.Context, chat maxChat) error {
member, err := m.api.Chats.GetMembership(ctx, chat.id)
if err != nil {
return fmt.Errorf("не удалось проверить права бота в канале MAX «%s»: %w", chat.title, err)
}
if !member.IsAdmin && !member.IsOwner {
return fmt.Errorf("бот не администратор канала MAX «%s»: публиковать в канале могут только администраторы", chat.title)
}
if !member.IsOwner && !slices.Contains(member.Permissions, "write") && !slices.Contains(member.Permissions, "post_edit_delete_message") {
return fmt.Errorf("боту нужно право публикации write в канале MAX «%s»", chat.title)
}
return nil
}
func (m *maxClient) checkRead(ctx context.Context, chat maxChat) error {
if chat.kind != model.ChatTypeChannel {
return fmt.Errorf("«%s» — не канал MAX", chat.title)
}
member, err := m.api.Chats.GetMembership(ctx, chat.id)
if err != nil {
return fmt.Errorf("не удалось проверить право чтения постов MAX «%s»: %w", chat.title, err)
}
if !member.IsOwner && (!member.IsAdmin || !slices.Contains(member.Permissions, "read_all_messages")) {
return fmt.Errorf("боту нужно право чтения сообщений в канале MAX «%s»", chat.title)
}
return nil
}
// get calls the MAX API endpoints the client library lacks.
func (m *maxClient) get(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set(maxbot.AuthorizationHeader, m.token)
resp, err := m.http.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 500))
return fmt.Errorf("MAX ответил %s: %s", resp.Status, bytes.TrimSpace(body))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func (m *maxClient) upload(ctx context.Context, kind model.UploadType, name string, file tgFile) (string, error) {
r, err := file.open()
if err != nil {
return "", err
}
defer func() { _ = r.Close() }()
return m.api.Upload.Upload(ctx, kind, r, name, file.size)
}
// The SDK retries its POST even after a lost response. Use one HTTP request
// here so that only confirmed rejections can be retried by the delivery book.
func (m *maxClient) send(ctx context.Context, chatID int64, html string, attachments []attachment, preview bool) ([]string, error) {
ctx = withPacingChannel(ctx, strconv.FormatInt(chatID, 10))
msg := model.NewMessageBody{Text: html, Attachments: make([]model.Attachment, 0, len(attachments))}
if html != "" {
msg.Format = model.FormatHTML
}
for _, a := range attachments {
msg.Attachments = append(msg.Attachments, model.Attachment{Type: a.kind, Payload: model.Payload{Token: a.token}})
}
// MAX channels require notifications: notify=false rejects the message.
// Leave notify unset to use the platform default for every part.
query := url.Values{"chat_id": {strconv.FormatInt(chatID, 10)}}
if !preview {
query.Set("disable_link_preview", "true")
}
payload, err := json.Marshal(msg)
if err != nil {
return nil, &publishError{message: err.Error()}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.baseURL+"/messages?"+query.Encode(), bytes.NewReader(payload))
if err != nil {
return nil, &publishError{message: "не удалось собрать запрос публикации в MAX"}
}
req.Header.Set(maxbot.AuthorizationHeader, m.token)
req.Header.Set("Content-Type", "application/json")
resp, err := m.publish.Do(req)
if err != nil {
return nil, withoutURL(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
var apiErr maxbot.Error
_ = json.NewDecoder(io.LimitReader(resp.Body, 32<<10)).Decode(&apiErr)
message := fmt.Sprintf("MAX ответил %s: %s", resp.Status, apiErr.Error())
switch {
case resp.StatusCode == http.StatusTooManyRequests:
return nil, &publishError{message, rateLimitDelay(resp.Header.Get("Retry-After"))}
case definitivelyRejected(resp.StatusCode) && apiErr.IsAttachmentNotReady():
return nil, &publishError{message, 2 * time.Second}
case definitivelyRejected(resp.StatusCode):
return nil, &publishError{message: message}
default:
return nil, errors.New(message)
}
}
var result model.SendMessageResult
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&result); err != nil {
return nil, fmt.Errorf("ответ публикации MAX: %w", err)
}
if result.Message.Body.Mid == "" {
return nil, errors.New("MAX не вернул mid")
}
return []string{result.Message.Body.Mid}, nil
}