Ctrl K
ComunidadGuías…

max.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}