diff --git a/Dockerfile b/Dockerfile index f8247ce..6fa6946 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,9 @@ RUN BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) && \ go build -ldflags "-X main.Version=${VERSION} -X main.BuildDate=$BUILD_DATE" -o chat-bridge . FROM alpine:latest +RUN apk add --no-cache ca-certificates ffmpeg python3 py3-pip \ + && pip3 install --break-system-packages --no-cache-dir yt-dlp \ + && yt-dlp --version WORKDIR /app COPY --from=builder /app/chat-bridge . diff --git a/README.md b/README.md index 09c0700..66e7742 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - **Медиа**: фото, документы, видео, аудио, голосовые, GIF - В **Discord** и **Telegram** медиа отправляются как вложения (файлы) - В **VK** и на **сайт** — ссылками на прокси (без токена бота в URL) + - **YouTube → MAX**: если в сообщении есть ссылка на YouTube и ролик **короче 5 минут**, видео скачивается (`yt-dlp`) и отправляется в MAX как вложение (лимит MAX 250 МБ); иначе остаётся ссылка - **Медиа-прокси**: ссылки из Telegram не содержат токен бота; файлы отдаются через ваш сервер - **Webhook** для приёма сообщений с сайта (POST `/webhook`) - Загрузка переменных из файла `.env` при запуске @@ -120,6 +121,8 @@ docker-compose up -d --build 4. **Long polling** (по умолчанию) — для разработки и тестирования; запускается автоматически при наличии `MAX_TOKEN`. 5. **Webhook** (production) — задайте `MAX_USE_WEBHOOK=true`, `PUBLIC_URL=https://...` (HTTPS на порту 443) и опционально `MAX_WEBHOOK_SECRET`. При старте бот зарегистрирует подписку `POST /subscriptions` на `PUBLIC_URL/max/webhook`. +Для пересылки коротких YouTube-роликов в MAX на хосте (или в Docker-образе) нужны `yt-dlp` и `ffmpeg`. + ## Логи При старте выводятся: diff --git a/main.go b/main.go index f8f0104..77666cb 100644 --- a/main.go +++ b/main.go @@ -89,7 +89,7 @@ func main() { } if maxChatID != "" { if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil { - _ = bridge.SendToMAX(id, "[TG] "+sender+": "+msg) + _ = bridge.SendToMAXSmart(id, "[TG] "+sender+": "+msg) } } _ = bridge.SendToSite("[TG] " + sender + ": " + msg) @@ -118,7 +118,7 @@ func main() { } if maxChatID != "" { if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil { - _ = bridge.SendToMAX(id, "[DS] "+sender+": "+msg) + _ = bridge.SendToMAXSmart(id, "[DS] "+sender+": "+msg) } } _ = bridge.SendToSite("[DS] " + sender + ": " + msg) @@ -155,7 +155,7 @@ func main() { // Для сайта оставляем ссылки if maxChatID != "" { if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil { - _ = bridge.SendToMAX(id, "[VK] "+sender+": "+msg) + _ = bridge.SendToMAXSmart(id, "[VK] "+sender+": "+msg) } } _ = bridge.SendToSite("[VK] " + sender + ": " + msg) @@ -176,7 +176,7 @@ func main() { } if maxChatID != "" { if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil { - _ = bridge.SendToMAX(id, "[SITE] "+sender+": "+msg) + _ = bridge.SendToMAXSmart(id, "[SITE] "+sender+": "+msg) } } case "max": diff --git a/max_client.go b/max_client.go index 160ba60..7be8d7b 100644 --- a/max_client.go +++ b/max_client.go @@ -5,9 +5,15 @@ import ( "encoding/json" "fmt" "io" + "log" + "mime/multipart" "net/http" "net/url" + "os" + "path/filepath" "strconv" + "strings" + "time" ) const maxAPIBase = "https://platform-api.max.ru" @@ -98,10 +104,119 @@ func (b *Bridge) maxRequest(method, path string, query url.Values, body any) ([] func (b *Bridge) SendToMAX(chatID int64, text string) error { query := url.Values{"chat_id": {strconv.FormatInt(chatID, 10)}} - _, err := b.maxRequest(http.MethodPost, "/messages", query, map[string]string{"text": text}) + _, err := b.maxRequest(http.MethodPost, "/messages", query, map[string]any{"text": text}) return err } +type maxUploadURLResponse struct { + URL string `json:"url"` + Token string `json:"token"` +} + +// SendVideoFileToMAX загружает локальный файл как video и отправляет в чат. +func (b *Bridge) SendVideoFileToMAX(chatID int64, filePath, text string) error { + query := url.Values{"type": {"video"}} + data, err := b.maxRequest(http.MethodPost, "/uploads", query, nil) + if err != nil { + return err + } + var up maxUploadURLResponse + if err := json.Unmarshal(data, &up); err != nil { + return err + } + if up.URL == "" || up.Token == "" { + return fmt.Errorf("MAX /uploads: пустой url или token: %s", string(data)) + } + + if err := b.uploadFileToMAXURL(up.URL, filePath); err != nil { + return err + } + + body := map[string]any{ + "text": text, + "attachments": []map[string]any{ + { + "type": "video", + "payload": map[string]string{ + "token": up.Token, + }, + }, + }, + } + msgQuery := url.Values{"chat_id": {strconv.FormatInt(chatID, 10)}} + + // После загрузки MAX может ещё обрабатывать файл — повторяем при attachment.not.ready + var lastErr error + for attempt := 0; attempt < 8; attempt++ { + if attempt > 0 { + wait := time.Duration(attempt*attempt) * time.Second + log.Printf("[MAX] ожидание обработки видео %s…", wait) + time.Sleep(wait) + } + _, lastErr = b.maxRequest(http.MethodPost, "/messages", msgQuery, body) + if lastErr == nil { + return nil + } + if !strings.Contains(lastErr.Error(), "attachment.not.ready") && + !strings.Contains(lastErr.Error(), "not.processed") { + return lastErr + } + } + return lastErr +} + +func (b *Bridge) uploadFileToMAXURL(uploadURL, filePath string) error { + f, err := os.Open(filePath) + if err != nil { + return err + } + defer f.Close() + + pr, pw := io.Pipe() + w := multipart.NewWriter(pw) + errCh := make(chan error, 1) + go func() { + defer pw.Close() + part, err := w.CreateFormFile("data", filepath.Base(filePath)) + if err != nil { + errCh <- err + _ = pw.CloseWithError(err) + return + } + if _, err := io.Copy(part, f); err != nil { + errCh <- err + _ = pw.CloseWithError(err) + return + } + errCh <- w.Close() + }() + + req, err := http.NewRequest(http.MethodPost, uploadURL, pr) + if err != nil { + _ = pr.Close() + return err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + if b.MAXToken != "" { + req.Header.Set("Authorization", b.MAXToken) + } + + resp, err := http.DefaultClient.Do(req) + writeErr := <-errCh + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if writeErr != nil { + return writeErr + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("MAX upload file: %d %s", resp.StatusCode, string(respBody)) + } + return nil +} + func (b *Bridge) GetMAXUpdates(marker *int64, timeout int) (*maxUpdatesResponse, error) { query := url.Values{ "limit": {"100"}, diff --git a/youtube.go b/youtube.go new file mode 100644 index 0000000..617d6a3 --- /dev/null +++ b/youtube.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +const ( + maxYouTubeDurationSec = 5 * 60 + maxYouTubeFileBytes = 250 * 1024 * 1024 // лимит MAX для video +) + +var youtubeURLRe = regexp.MustCompile(`(?i)https?://(?:(?:www|m)\.)?(?:youtube\.com/(?:watch\?[^ \t\n]*v=|shorts/|embed/|live/)|youtu\.be/)[^\s<>"']+`) + +func extractYouTubeURL(text string) string { + m := youtubeURLRe.FindString(text) + if m == "" { + return "" + } + return strings.TrimRight(m, ".,);]!?") +} + +type ytDLPInfo struct { + Duration float64 `json:"duration"` + Title string `json:"title"` + ID string `json:"id"` +} + +func getYouTubeInfo(videoURL string) (*ytDLPInfo, error) { + cmd := exec.Command("yt-dlp", "--dump-json", "--no-playlist", "--no-download", videoURL) + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("yt-dlp info: %v: %s", err, string(ee.Stderr)) + } + return nil, fmt.Errorf("yt-dlp не найден или ошибка: %w", err) + } + var info ytDLPInfo + if err := json.Unmarshal(out, &info); err != nil { + return nil, err + } + return &info, nil +} + +// downloadYouTubeIfShort скачивает ролик, если он короче 5 минут и уложится в лимит MAX. +// Возвращает путь к временному файлу (caller должен удалить) или ошибку. +func downloadYouTubeIfShort(videoURL string) (filePath string, title string, err error) { + info, err := getYouTubeInfo(videoURL) + if err != nil { + return "", "", err + } + if info.Duration <= 0 { + return "", "", fmt.Errorf("не удалось определить длительность") + } + if info.Duration > float64(maxYouTubeDurationSec) { + return "", "", fmt.Errorf("видео длиннее 5 минут (%.0f с)", info.Duration) + } + + tmpDir, err := os.MkdirTemp("", "bridge-yt-*") + if err != nil { + return "", "", err + } + outTmpl := filepath.Join(tmpDir, "video.%(ext)s") + + // mp4 предпочтительнее для MAX; ограничение размера 250 МБ + cmd := exec.Command( + "yt-dlp", + "--no-playlist", + "--max-filesize", strconv.Itoa(maxYouTubeFileBytes), + "-f", "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b", + "--merge-output-format", "mp4", + "-o", outTmpl, + videoURL, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + _ = os.RemoveAll(tmpDir) + return "", "", fmt.Errorf("yt-dlp download: %w", err) + } + + entries, err := os.ReadDir(tmpDir) + if err != nil || len(entries) == 0 { + _ = os.RemoveAll(tmpDir) + return "", "", fmt.Errorf("файл после скачивания не найден") + } + filePath = filepath.Join(tmpDir, entries[0].Name()) + st, err := os.Stat(filePath) + if err != nil { + _ = os.RemoveAll(tmpDir) + return "", "", err + } + if st.Size() > maxYouTubeFileBytes { + _ = os.RemoveAll(tmpDir) + return "", "", fmt.Errorf("файл больше лимита MAX 250 МБ (%d)", st.Size()) + } + log.Printf("[YouTube] скачано: %s (%.0f с, %d байт)", info.Title, info.Duration, st.Size()) + return filePath, info.Title, nil +} + +func cleanupYouTubeFile(filePath string) { + if filePath == "" { + return + } + _ = os.RemoveAll(filepath.Dir(filePath)) +} + +// SendToMAXSmart: при YouTube-ссылке и ролике < 5 мин отправляет видео-вложение, иначе текст (со ссылкой). +func (b *Bridge) SendToMAXSmart(chatID int64, text string) error { + ytURL := extractYouTubeURL(text) + if ytURL == "" { + return b.SendToMAX(chatID, text) + } + + filePath, title, err := downloadYouTubeIfShort(ytURL) + if err != nil { + log.Printf("[YouTube→MAX] не удалось скачать (%s), отправляю ссылкой: %v", ytURL, err) + return b.SendToMAX(chatID, text) + } + defer cleanupYouTubeFile(filePath) + + caption := text + if title != "" && !strings.Contains(text, title) { + caption = text + "\n" + title + } + if err := b.SendVideoFileToMAX(chatID, filePath, caption); err != nil { + log.Printf("[YouTube→MAX] ошибка загрузки видео, отправляю ссылкой: %v", err) + return b.SendToMAX(chatID, text) + } + return nil +}