max-telegram-bridge-bot/upload.go
Andrey Lugovskoy 77fa94914f
Some checks failed
Build / build (push) Has been cancelled
Fix: video-without-caption TG→MAX rejected as send-message.empty
v0.3.14 started sending format="markdown" for every TG→MAX forward
(including crossposts). For media-only posts (video without caption,
etc.) text ends up empty and MAX API rejects the payload with
  400 proto.payload errors.send-message.empty
because format is set but no text is present.

sendMaxDirectFormatted now drops format when text is "". This also
unsticks queued items that were retrying forever with the same 400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:40:53 +04:00

427 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"strings"
"time"
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
)
// downloadURL скачивает файл по URL и возвращает bytes.
func (b *Bridge) downloadURL(url string) ([]byte, error) {
slog.Debug("downloadURL", "url", url)
resp, err := b.httpClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
slog.Debug("downloadURL response", "status", resp.StatusCode, "contentLength", resp.ContentLength, "url", url)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("download status %d url: %s", resp.StatusCode, url)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if len(data) == 0 {
slog.Warn("downloadURL: empty body", "url", url, "contentLength", resp.ContentLength)
return nil, fmt.Errorf("downloaded 0 bytes from %s", url)
}
slog.Debug("downloadURL ok", "size", len(data))
return data, nil
}
// sendTgMediaFromURL скачивает файл с URL и отправляет в TG как upload.
// maxBytes=0 means no size limit. fileName overrides name extracted from URL.
func (b *Bridge) sendTgMediaFromURL(ctx context.Context, tgChatID int64, mediaURL, mediaType, caption, parseMode string, replyToID, threadID int, maxBytes int64, fileName ...string) (int, error) {
slog.Debug("sendTgMediaFromURL start", "url", mediaURL, "type", mediaType, "tgChat", tgChatID)
data, nameFromURL, err := b.downloadURLWithLimit(mediaURL, maxBytes)
if err == nil {
slog.Debug("sendTgMediaFromURL downloaded", "size", len(data), "name", nameFromURL)
}
if err != nil {
return 0, fmt.Errorf("download media: %w", err)
}
name := nameFromURL
if len(fileName) > 0 && fileName[0] != "" {
name = fileName[0]
}
file := FileArg{Name: name, Bytes: data}
switch mediaType {
case "photo":
return b.tg.SendPhoto(ctx, tgChatID, file, &SendOpts{
Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID,
})
case "video":
return b.tg.SendVideo(ctx, tgChatID, file, &SendOpts{
Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID,
})
case "audio":
return b.tg.SendAudio(ctx, tgChatID, file, &SendOpts{
Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID,
})
case "file":
return b.tg.SendDocument(ctx, tgChatID, file, &SendOpts{
Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID,
})
default:
// sticker и прочее — как фото
return b.tg.SendPhoto(ctx, tgChatID, file, &SendOpts{
Caption: caption, ThreadID: threadID,
})
}
}
// customUploadToMax — обход бага SDK: CDN возвращает XML вместо JSON
func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.UploadType, reader io.Reader, fileName string) (*maxschemes.UploadedInfo, error) {
// 1. Получаем URL и token от MAX API
apiURL := fmt.Sprintf("https://platform-api.max.ru/uploads?type=%s&v=1.2.5", string(uploadType))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", b.cfg.MaxToken)
resp, err := b.apiClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get upload url: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("upload endpoint status: %d", resp.StatusCode)
}
endpointBody, _ := io.ReadAll(resp.Body)
slog.Debug("MAX upload endpoint response", "status", resp.StatusCode, "body", string(endpointBody))
var endpoint maxschemes.UploadEndpoint
if err := json.Unmarshal(endpointBody, &endpoint); err != nil {
return nil, fmt.Errorf("decode upload endpoint: %w", err)
}
slog.Debug("MAX upload endpoint", "url", endpoint.Url, "token", endpoint.Token)
// Для video/audio: token приходит сразу, но файл ВСЁ РАВНО нужно загрузить на CDN URL.
// Для file/image: token приходит после загрузки на CDN.
videoToken := endpoint.Token // сохраняем для video/audio
if endpoint.Url == "" && videoToken != "" {
// Нет URL для загрузки, но есть token — file/image (не video/audio)
slog.Debug("MAX upload ok (endpoint token, no CDN needed)")
return &maxschemes.UploadedInfo{Token: videoToken}, nil
}
if endpoint.Url == "" {
return nil, fmt.Errorf("upload endpoint returned empty URL and no token")
}
// 2. Загружаем файл на CDN (multipart)
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("data", fileName)
if err != nil {
return nil, fmt.Errorf("create form file: %w", err)
}
if _, err := io.Copy(part, reader); err != nil {
return nil, fmt.Errorf("copy to form: %w", err)
}
writer.Close()
cdnReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.Url, &buf)
if err != nil {
return nil, fmt.Errorf("create CDN request: %w", err)
}
cdnReq.Header.Set("Content-Type", writer.FormDataContentType())
cdnResp, err := b.httpClient.Do(cdnReq)
if err != nil {
return nil, fmt.Errorf("upload to CDN: %w", err)
}
defer cdnResp.Body.Close()
cdnBody, _ := io.ReadAll(cdnResp.Body)
slog.Debug("MAX CDN response", "status", cdnResp.StatusCode, "body", string(cdnBody))
// Проверяем ошибку запрещённого расширения
var apiErr struct {
Code string `json:"code"`
Message string `json:"message"`
}
if json.Unmarshal(cdnBody, &apiErr) == nil && apiErr.Code == "upload.error" {
slog.Warn("MAX upload rejected", "code", apiErr.Code, "message", apiErr.Message, "file", fileName)
return nil, &ErrForbiddenExtension{Name: fileName}
}
// 3. Для video/audio: используем token из шага 1 (CDN возвращает только retval)
if videoToken != "" {
slog.Debug("MAX upload ok (video/audio token from endpoint)", "token", videoToken)
return &maxschemes.UploadedInfo{Token: videoToken}, nil
}
// Для file/image: парсим CDN ответ (fileId + token в camelCase)
var cdnResult struct {
FileID int64 `json:"fileId"`
Token string `json:"token"`
}
if err := json.Unmarshal(cdnBody, &cdnResult); err == nil && cdnResult.Token != "" {
slog.Debug("MAX upload ok", "fileId", cdnResult.FileID)
return &maxschemes.UploadedInfo{Token: cdnResult.Token, FileID: cdnResult.FileID}, nil
}
return nil, fmt.Errorf("no token in CDN response: %s", string(cdnBody))
}
// uploadTgPhotoToMax скачивает фото из TG и загружает в MAX через SDK (возвращает PhotoTokens).
func (b *Bridge) uploadTgPhotoToMax(ctx context.Context, fileID string) (*maxschemes.PhotoTokens, error) {
fileURL, err := b.tgFileURL(ctx, fileID)
if err != nil {
return nil, fmt.Errorf("tg getFileURL: %w", err)
}
dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
resp, err := b.httpClient.Do(dlReq)
if err != nil {
return nil, fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("tg download status: %d", resp.StatusCode)
}
return b.maxApi.Uploads.UploadPhotoFromReader(ctx, resp.Body)
}
// uploadTgMediaToMax скачивает файл из TG и загружает в MAX
func (b *Bridge) uploadTgMediaToMax(ctx context.Context, fileID string, uploadType maxschemes.UploadType, fileName string) (*maxschemes.UploadedInfo, error) {
fileURL, err := b.tgFileURL(ctx, fileID)
if err != nil {
return nil, fmt.Errorf("tg getFileURL: %w", err)
}
dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
resp, err := b.httpClient.Do(dlReq)
if err != nil {
return nil, fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("tg download status: %d url: %s", resp.StatusCode, fileURL)
}
slog.Debug("TG file downloaded", "size", resp.ContentLength)
return b.customUploadToMax(ctx, uploadType, resp.Body, fileName)
}
// sendMaxDirect — отправка сообщения в MAX напрямую (обход SDK)
func (b *Bridge) sendMaxDirect(ctx context.Context, chatID int64, text string, attType string, token string, replyTo string) (string, error) {
return b.sendMaxDirectFormatted(ctx, chatID, text, attType, token, replyTo, "")
}
func (b *Bridge) sendMaxDirectFormatted(ctx context.Context, chatID int64, text string, attType string, token string, replyTo string, format string) (string, error) {
type attachment struct {
Type string `json:"type"`
Payload map[string]string `json:"payload"`
}
type msgBody struct {
Text string `json:"text,omitempty"`
Attachments []attachment `json:"attachments,omitempty"`
Format string `json:"format,omitempty"`
Link *struct {
Type string `json:"type"`
Mid string `json:"mid"`
} `json:"link,omitempty"`
}
// format применяется только к тексту — при пустом тексте MAX отклоняет payload.
if text == "" {
format = ""
}
body := msgBody{Text: text, Format: format}
if attType != "" && token != "" {
body.Attachments = []attachment{{
Type: attType,
Payload: map[string]string{"token": token},
}}
}
if replyTo != "" {
body.Link = &struct {
Type string `json:"type"`
Mid string `json:"mid"`
}{Type: "reply", Mid: replyTo}
}
data, err := json.Marshal(body)
if err != nil {
return "", err
}
url := fmt.Sprintf("https://platform-api.max.ru/messages?chat_id=%d&v=1.2.5", chatID)
// Пауза перед первой отправкой если есть вложение (MAX CDN нужно время на обработку)
if attType != "" && token != "" {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(3 * time.Second):
}
}
// Retry при attachment.not.ready (файл ещё обрабатывается)
for attempt := 0; attempt < 20; attempt++ {
if attempt > 0 {
delay := time.Duration(3+attempt*2) * time.Second
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(delay):
}
slog.Warn("MAX retry", "attempt", attempt+1, "maxAttempts", 20)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return "", err
}
req.Header.Set("Authorization", b.cfg.MaxToken)
req.Header.Set("Content-Type", "application/json")
resp, err := b.apiClient.Do(req)
if err != nil {
return "", err
}
respBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == 200 {
var result struct {
Message struct {
Body struct {
Mid string `json:"mid"`
} `json:"body"`
} `json:"message"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", err
}
return result.Message.Body.Mid, nil
}
// Проверяем attachment.not.ready — ретраим
if resp.StatusCode == 400 && strings.Contains(string(respBody), "attachment.not.ready") {
slog.Warn("MAX attachment not ready, waiting")
continue
}
return "", fmt.Errorf("MAX API %d: %s", resp.StatusCode, string(respBody))
}
return "", fmt.Errorf("MAX attachment not ready after 10 retries")
}
// formatFileSize formats file size in human-readable form.
func formatFileSize(size int) string {
switch {
case size >= 1024*1024:
return fmt.Sprintf("%.1f МБ", float64(size)/1024/1024)
case size >= 1024:
return fmt.Sprintf("%.1f КБ", float64(size)/1024)
default:
return fmt.Sprintf("%d Б", size)
}
}
// ErrFileTooLarge is returned when file exceeds the configured size limit.
type ErrFileTooLarge struct {
Size int64
Name string
}
func (e *ErrFileTooLarge) Error() string {
return fmt.Sprintf("file too large: %s (%s)", e.Name, formatFileSize(int(e.Size)))
}
// ErrForbiddenExtension is returned when MAX API rejects the file extension.
type ErrForbiddenExtension struct {
Name string
}
func (e *ErrForbiddenExtension) Error() string {
return fmt.Sprintf("file extension forbidden by MAX: %s", e.Name)
}
// downloadURLWithLimit downloads a file from URL with an optional size limit.
// maxBytes=0 means no limit. Returns bytes and filename from Content-Disposition or URL.
func (b *Bridge) downloadURLWithLimit(url string, maxBytes int64) ([]byte, string, error) {
slog.Debug("downloadURLWithLimit start", "url", url, "maxBytes", maxBytes)
resp, err := b.httpClient.Get(url)
if err != nil {
slog.Error("downloadURLWithLimit failed", "err", err, "url", url)
return nil, "", err
}
defer resp.Body.Close()
slog.Debug("downloadURLWithLimit response", "status", resp.StatusCode, "contentLength", resp.ContentLength, "url", url)
if resp.StatusCode != 200 {
return nil, "", fmt.Errorf("download status %d", resp.StatusCode)
}
// Extract filename from Content-Disposition
name := ""
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
if i := strings.Index(cd, "filename=\""); i >= 0 {
rest := cd[i+len("filename=\""):]
if j := strings.Index(rest, "\""); j >= 0 {
name = rest[:j]
}
}
if name == "" {
if i := strings.Index(cd, "filename="); i >= 0 {
rest := strings.TrimSpace(cd[i+len("filename="):])
if j := strings.IndexAny(rest, "; \t"); j >= 0 {
name = rest[:j]
} else {
name = rest
}
}
}
}
if name == "" {
name = fileNameFromURL(url)
}
// Fast check via Content-Length
if maxBytes > 0 && resp.ContentLength > maxBytes {
return nil, name, &ErrFileTooLarge{Size: resp.ContentLength, Name: name}
}
// Read body
var data []byte
if maxBytes > 0 {
data, err = io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
} else {
data, err = io.ReadAll(resp.Body)
}
if err != nil {
return nil, "", err
}
if maxBytes > 0 && int64(len(data)) > maxBytes {
return nil, name, &ErrFileTooLarge{Size: int64(len(data)), Name: name}
}
return data, name, nil
}