424 lines
14 KiB
Go
424 lines
14 KiB
Go
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"`
|
|
}
|
|
|
|
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
|
|
}
|