diff --git a/internal/service/notify_channels_test.go b/internal/service/notify_channels_test.go index aa267cc..3f38405 100644 --- a/internal/service/notify_channels_test.go +++ b/internal/service/notify_channels_test.go @@ -72,7 +72,22 @@ func TestTelegramDispatchUsesPhotoAndFormattedCaption(t *testing.T) { Title: "MediaStationGo 下载完成", Message: "任务:示例影片\n保存路径:/downloads/movie\nHash:abcdef", Data: map[string]interface{}{ - "poster_url": server.URL + "/poster.jpg", + "poster_url": server.URL + "/poster.jpg", + "media_type": "tv", + "media_category": "纪录片剧集", + "title": "美国甜心:达拉斯牛仔啦啦队", + "original_title": "AMERICA'S SWEETHEARTS: Dallas Cowboys Cheerleaders", + "original_language": "en", + "year": 2024, + "season_episode": "S03E07", + "size": "3.0GB / 5.7Mbps", + "version": "H264.NF.FHD-HHWEB", + "rating": 8.2, + "genres": "纪录", + "overview": "从试镜到训练营再到 NFL 赛季,一路跟随达拉斯牛仔队啦啦队队员们追逐梦想。", + "tmdb_url": server.URL + "/tmdb", + "imdb_url": server.URL + "/imdb", + "douban_url": server.URL + "/douban", }, }) if err != nil { @@ -85,9 +100,30 @@ func TestTelegramDispatchUsesPhotoAndFormattedCaption(t *testing.T) { t.Fatalf("telegram form = %#v", gotForm) } caption := gotForm["caption"] - for _, want := range []string{"MediaStationGo", "下载完成", "任务: 示例影片", "/downloads/movie", "abcdef"} { + for _, want := range []string{ + "🐈‍⬛🐈‍⬛ MediaStationGo 更新啦 🐈‍⬛🐈‍⬛", + "--------------------------------", + "#剧集", + "📺 中文片名:美国甜心:达拉斯牛仔啦啦队", + "🧿 原始片名:AMERICA'S SWEETHEARTS: Dallas Cowboys Cheerleaders", + "🌐 原始语言:英语", + "📅 发行年份:2024", + "🐈‍⬛ 类别:纪录片剧集", + "🫧 季集:S03E07", + "🔎 大小:3.0GB / 5.7Mbps", + "📁 版本:H264.NF.FHD-HHWEB", + "⭐️ 评分:8.2", + "💎 类型:纪录", + "🪬 简介:", + `🔗 外链:TMDB / IMDB / 豆瓣`, + } { if !strings.Contains(caption, want) { t.Fatalf("caption missing %q: %s", want, caption) } } + for _, unwanted := range []string{"✅ 下载完成", "🎯 订阅命中新资源", "保存路径", "abcdef"} { + if strings.Contains(caption, unwanted) { + t.Fatalf("caption should not include %q: %s", unwanted, caption) + } + } } diff --git a/internal/service/notify_telegram.go b/internal/service/notify_telegram.go index 2c4c55e..a4c07b3 100644 --- a/internal/service/notify_telegram.go +++ b/internal/service/notify_telegram.go @@ -5,9 +5,12 @@ import ( "context" "fmt" "net/url" + "regexp" "sort" + "strconv" "strings" "time" + "unicode/utf8" ) // TelegramProvider 通过 Telegram Bot API 发送通知。 @@ -31,7 +34,7 @@ func (p *TelegramProvider) Send(ctx context.Context, cfg map[string]string, even var firstErr error for _, chatID := range chatIDs { - if photoURL != "" && len(text) <= 1024 { + if photoURL != "" && utf8.RuneCountInString(text) <= 1024 { payload := map[string]string{ "chat_id": chatID, "photo": photoURL, @@ -43,6 +46,20 @@ func (p *TelegramProvider) Send(ctx context.Context, cfg map[string]string, even } else if firstErr == nil { firstErr = err } + if photo, _, err := telegramFetchRemotePhoto(ctx, cfg, photoURL, 15*time.Second); err == nil { + fields := map[string]string{ + "chat_id": chatID, + "caption": text, + "parse_mode": parseMode, + } + if err := telegramPostMultipart(ctx, cfg, "sendPhoto", fields, "photo", "poster.jpg", photo, 20*time.Second); err == nil { + continue + } else if firstErr == nil { + firstErr = err + } + } else if firstErr == nil { + firstErr = err + } } payload := map[string]string{ "chat_id": chatID, @@ -85,26 +102,32 @@ func formatTelegramMessage(event NotifyEvent, parseMode string) string { } func formatTelegramNotification(event NotifyEvent) string { + if text := formatTelegramMediaNotification(event); text != "" { + return text + } + var sb strings.Builder - title := strings.TrimSpace(event.Title) - if strings.HasPrefix(title, "MediaStationGo ") { - sb.WriteString("MediaStationGo\n") - sb.WriteString(fmt.Sprintf("%s", escapeHTML(strings.TrimSpace(strings.TrimPrefix(title, "MediaStationGo "))))) - } else if title != "" { - sb.WriteString(fmt.Sprintf("%s", escapeHTML(title))) - } else { - sb.WriteString("MediaStationGo") + if tag := telegramEventTag(event); tag != "" { + sb.WriteString(tag) + if telegramShouldShowEventHeading(event) { + sb.WriteString("\n") + } + } + if telegramShouldShowEventHeading(event) { + sb.WriteString(telegramEventHeading(event)) } message := strings.TrimSpace(event.Message) if message != "" { - sb.WriteString("\n\n") + if sb.Len() > 0 { + sb.WriteString("\n\n") + } sb.WriteString(formatTelegramBody(message)) } fields := telegramDisplayData(event.Data) if len(fields) > 0 { - if message != "" { + if sb.Len() > 0 { sb.WriteString("\n") } sb.WriteString("\n") @@ -113,10 +136,189 @@ func formatTelegramNotification(event NotifyEvent) string { sb.WriteString("\n") } } + if links := telegramExternalLinks(event.Data); links != "" { + sb.WriteString("\n") + sb.WriteString(links) + } return strings.TrimSpace(sb.String()) } +const ( + telegramMediaTemplateHeader = "🐈‍⬛🐈‍⬛ MediaStationGo 更新啦 🐈‍⬛🐈‍⬛" + telegramMediaTemplateSeparator = "--------------------------------" +) + +var telegramSeasonEpisodePattern = regexp.MustCompile(`(?i)S\d{1,2}E\d{1,3}(?:[\-.~_ ]?E?\d{1,3})?`) + +func formatTelegramMediaNotification(event NotifyEvent) string { + tag := telegramMediaTag(event.Data) + if tag == "" { + return "" + } + + title := telegramFirstValue(event.Data, "chinese_title", "title", "name", "media_title") + if title == "" { + title = telegramMessageFieldValue(event.Message, "任务", "订阅", "媒体", "资源") + } + originalTitle := telegramFirstValue(event.Data, "original_title", "original_name") + originalLanguage := telegramLanguageName(telegramFirstValue(event.Data, "original_language", "language", "languages")) + year := telegramFirstValue(event.Data, "year", "release_year") + category := telegramMediaCategory(event.Data) + seasonEpisode := telegramSeasonEpisodeValue(event) + size := telegramSizeValue(event.Data) + version := telegramVersionValue(event, seasonEpisode) + rating := telegramFirstValue(event.Data, "rating", "score") + genres := telegramGenresValue(telegramFirstValue(event.Data, "genres", "genre", "type")) + overview := telegramFirstValue(event.Data, "overview", "summary", "description") + links := telegramExternalLinks(event.Data) + + topFields := []string{} + if title != "" { + topFields = append(topFields, "📺 中文片名:"+escapeHTML(title)) + } + if originalTitle != "" && !strings.EqualFold(originalTitle, title) { + topFields = append(topFields, "🧿 原始片名:"+escapeHTML(originalTitle)) + } + if originalLanguage != "" { + topFields = append(topFields, "🌐 原始语言:"+escapeHTML(originalLanguage)) + } + if year != "" && year != "0" { + topFields = append(topFields, "📅 发行年份:"+escapeHTML(year)) + } + + mediaFields := []string{} + if category != "" { + mediaFields = append(mediaFields, "🐈‍⬛ 类别:"+escapeHTML(category)) + } + if seasonEpisode != "" { + mediaFields = append(mediaFields, "🫧 季集:"+escapeHTML(seasonEpisode)) + } + if size != "" { + mediaFields = append(mediaFields, "🔎 大小:"+escapeHTML(size)) + } + if version != "" { + mediaFields = append(mediaFields, "📁 版本:"+escapeHTML(version)) + } + + infoFields := []string{} + if rating != "" && rating != "0" { + infoFields = append(infoFields, "⭐️ 评分:"+escapeHTML(rating)) + } + if genres != "" { + infoFields = append(infoFields, "💎 类型:"+escapeHTML(genres)) + } + if overview != "" { + infoFields = append(infoFields, "🪬 简介:\n"+escapeHTML(overview)) + } + + if len(topFields) == 0 && len(mediaFields) == 0 && len(infoFields) == 0 && links == "" { + return "" + } + + lines := []string{telegramMediaTemplateHeader, telegramMediaTemplateSeparator, tag} + lines = append(lines, topFields...) + if len(mediaFields) > 0 { + if len(topFields) > 0 { + lines = append(lines, "") + } + lines = append(lines, mediaFields...) + } + if len(infoFields) > 0 { + if len(topFields) > 0 || len(mediaFields) > 0 { + lines = append(lines, "") + } + lines = append(lines, infoFields...) + } + if links != "" { + lines = append(lines, "", telegramMediaTemplateSeparator, links) + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func telegramShouldShowEventHeading(event NotifyEvent) bool { + if telegramMediaTag(event.Data) != "" { + return false + } + switch strings.TrimSpace(event.Type) { + case EventSubscriptionHit, EventDownloadComplete: + return false + default: + return true + } +} + +func telegramEventTag(event NotifyEvent) string { + if tag := telegramMediaTag(event.Data); tag != "" { + return tag + } + switch strings.TrimSpace(event.Type) { + case EventSubscriptionHit: + return "#订阅" + case EventDownloadComplete: + return "#下载完成" + case EventScrapeFailed: + return "#刮削失败" + case EventSystemAlert: + return "#系统提醒" + case EventLibraryIngest: + return "#入库" + default: + title := strings.TrimSpace(strings.TrimPrefix(event.Title, "MediaStationGo ")) + if title == "" { + return "#MediaStationGo" + } + return "#" + escapeHTML(strings.ReplaceAll(title, " ", "")) + } +} + +func telegramMediaTag(data map[string]interface{}) string { + for _, key := range []string{"media_category", "category", "media_type"} { + value := strings.TrimSpace(telegramDataString(data, key)) + if value == "" { + continue + } + lower := strings.ToLower(value) + switch { + case strings.Contains(value, "电影") || lower == "movie": + return "#电影" + case strings.Contains(value, "剧") || lower == "tv" || lower == "series" || lower == "show": + return "#剧集" + case strings.Contains(value, "动漫") || strings.Contains(value, "动画") || lower == "anime": + return "#动漫" + case strings.Contains(value, "综艺") || lower == "variety": + return "#综艺" + case strings.Contains(value, "纪录") || lower == "documentary": + return "#纪录片" + default: + return "#" + escapeHTML(strings.ReplaceAll(value, " ", "")) + } + } + return "" +} + +func telegramEventHeading(event NotifyEvent) string { + title := strings.TrimSpace(event.Title) + title = strings.TrimSpace(strings.TrimPrefix(title, "MediaStationGo ")) + if title == "" { + title = "MediaStationGo 通知" + } + icon := "🔔" + switch strings.TrimSpace(event.Type) { + case EventSubscriptionHit: + icon = "🎯" + case EventDownloadComplete: + icon = "✅" + case EventScrapeFailed: + icon = "⚠️" + case EventSystemAlert: + icon = "🚨" + case EventLibraryIngest: + icon = "📚" + } + return fmt.Sprintf("%s %s", icon, escapeHTML(title)) +} + func formatTelegramBody(message string) string { lines := strings.Split(message, "\n") out := make([]string, 0, len(lines)) @@ -130,6 +332,10 @@ func formatTelegramBody(message string) string { out = append(out, "- "+escapeHTML(strings.TrimSpace(strings.TrimPrefix(line, "- ")))) continue } + if key, ok := trimTelegramEmptyField(line); ok { + out = append(out, fmt.Sprintf("%s %s:", telegramFieldIcon(telegramFieldLabel(key)), escapeHTML(telegramFieldLabel(key)))) + continue + } if key, value, ok := splitTelegramField(line); ok { out = append(out, formatTelegramField(key, value)) continue @@ -139,6 +345,19 @@ func formatTelegramBody(message string) string { return strings.TrimSpace(strings.Join(out, "\n")) } +func trimTelegramEmptyField(line string) (string, bool) { + line = strings.TrimSpace(line) + for _, suffix := range []string{":", ":"} { + if strings.HasSuffix(line, suffix) { + key := strings.TrimSpace(strings.TrimSuffix(line, suffix)) + if key != "" && len([]rune(key)) <= 16 { + return key, true + } + } + } + return "", false +} + func splitTelegramField(line string) (string, string, bool) { idx := strings.Index(line, ":") sepLen := len(":") @@ -163,7 +382,7 @@ func formatTelegramField(key, value string) string { if telegramCodeField(key) { escapedValue = "" + escapedValue + "" } - return fmt.Sprintf("%s: %s", escapeHTML(key), escapedValue) + return fmt.Sprintf("%s %s:%s", telegramFieldIcon(key), escapeHTML(key), escapedValue) } func telegramCodeField(key string) bool { @@ -174,6 +393,262 @@ func telegramCodeField(key string) bool { strings.Contains(key, "id") } +func telegramFieldIcon(key string) string { + switch strings.ToLower(strings.TrimSpace(key)) { + case "中文片名", "标题", "任务", "媒体", "资源": + return "📺" + case "原始片名": + return "🧿" + case "原始语言", "语言": + return "🌐" + case "发行年份", "年份": + return "📅" + case "类别", "分类", "媒体类型": + return "🐈‍⬛" + case "季集", "集数": + return "🫧" + case "大小", "质量", "规格": + return "🔎" + case "版本", "保存路径": + return "📁" + case "评分": + return "⭐️" + case "类型": + return "💎" + case "简介": + return "🪬" + case "订阅": + return "🎯" + case "新增资源": + return "✨" + case "Hash": + return "🧿" + case "错误": + return "⚠️" + default: + return "•" + } +} + +func telegramFirstValue(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value := telegramDataString(data, key); value != "" { + return value + } + } + return "" +} + +func telegramMessageFieldValue(message string, keys ...string) string { + if strings.TrimSpace(message) == "" { + return "" + } + for _, line := range strings.Split(message, "\n") { + key, value, ok := splitTelegramField(line) + if !ok { + continue + } + for _, want := range keys { + if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(want)) { + return value + } + } + } + return "" +} + +func telegramMediaCategory(data map[string]interface{}) string { + if category := telegramFirstValue(data, "media_category", "category"); category != "" { + return category + } + switch strings.ToLower(telegramFirstValue(data, "media_type")) { + case "movie": + return "电影" + case "tv", "series", "show": + return "剧集" + case "anime": + return "动漫" + case "variety": + return "综艺" + case "documentary": + return "纪录片" + default: + return telegramFirstValue(data, "media_type") + } +} + +func telegramLanguageName(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parts := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ',' || r == '/' || r == '|' || r == '、' + }) + if len(parts) == 0 { + parts = []string{raw} + } + seen := map[string]struct{}{} + out := []string{} + for _, part := range parts { + part = strings.TrimSpace(strings.Trim(part, "[]")) + if part == "" { + continue + } + lower := strings.ToLower(strings.ReplaceAll(part, "_", "-")) + name := part + switch { + case strings.HasPrefix(lower, "zh") || lower == "cn" || lower == "cmn": + name = "中文" + case lower == "en" || strings.HasPrefix(lower, "en-"): + name = "英语" + case lower == "ja" || lower == "jp" || strings.HasPrefix(lower, "ja-"): + name = "日语" + case lower == "ko" || lower == "kr" || strings.HasPrefix(lower, "ko-"): + name = "韩语" + case lower == "fr" || strings.HasPrefix(lower, "fr-"): + name = "法语" + case lower == "de" || strings.HasPrefix(lower, "de-"): + name = "德语" + case lower == "es" || strings.HasPrefix(lower, "es-"): + name = "西班牙语" + case lower == "it" || strings.HasPrefix(lower, "it-"): + name = "意大利语" + case lower == "ru" || strings.HasPrefix(lower, "ru-"): + name = "俄语" + case lower == "th" || strings.HasPrefix(lower, "th-"): + name = "泰语" + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + return strings.Join(out, "、") +} + +func telegramSeasonEpisodeValue(event NotifyEvent) string { + if value := telegramFirstValue(event.Data, "season_episode", "episode_tag"); value != "" { + return strings.ToUpper(value) + } + for _, raw := range []string{ + telegramFirstValue(event.Data, "resource_title", "torrent_title", "release_title"), + telegramFirstValue(event.Data, "title", "name"), + event.Message, + } { + if value := telegramExtractSeasonEpisode(raw); value != "" { + return value + } + } + season := telegramFirstValue(event.Data, "season") + episode := telegramFirstValue(event.Data, "episode") + if season != "" && episode != "" { + return fmt.Sprintf("S%02dE%02d", telegramEpisodeNumber(season), telegramEpisodeNumber(episode)) + } + return "" +} + +func telegramEpisodeNumber(raw string) int { + raw = strings.TrimSpace(strings.TrimLeft(strings.ToUpper(raw), "SE")) + raw = strings.TrimLeft(raw, "0") + if raw == "" { + return 0 + } + n, _ := strconv.Atoi(raw) + return n +} + +func telegramExtractSeasonEpisode(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + return strings.ToUpper(telegramSeasonEpisodePattern.FindString(raw)) +} + +func telegramSizeValue(data map[string]interface{}) string { + size := telegramFirstValue(data, "size") + bitrate := telegramFirstValue(data, "bitrate") + if size != "" && bitrate != "" { + return size + " / " + bitrate + } + if size != "" { + return size + } + return bitrate +} + +func telegramVersionValue(event NotifyEvent, seasonEpisode string) string { + if version := telegramFirstValue(event.Data, "version", "release_group"); version != "" && !strings.EqualFold(version, "best") { + return version + } + return telegramVersionFromResourceTitle( + telegramFirstValue(event.Data, "resource_title", "torrent_title", "release_title"), + seasonEpisode, + telegramFirstValue(event.Data, "year", "release_year"), + ) +} + +func telegramVersionFromResourceTitle(raw, seasonEpisode, year string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + tail := "" + if seasonEpisode != "" { + upperRaw := strings.ToUpper(raw) + upperEpisode := strings.ToUpper(seasonEpisode) + if idx := strings.Index(upperRaw, upperEpisode); idx >= 0 { + tail = raw[idx+len(seasonEpisode):] + } + } + if tail == "" && year != "" { + if idx := strings.LastIndex(raw, year); idx >= 0 { + tail = raw[idx+len(year):] + } + } + tail = strings.Trim(tail, " \t\r\n._-[]()【】") + if tail == "" { + return "" + } + tail = strings.TrimSuffix(tail, ".torrent") + tail = strings.TrimSuffix(tail, ".mkv") + tail = strings.TrimSuffix(tail, ".mp4") + tail = strings.Join(strings.Fields(tail), ".") + if len([]rune(tail)) > 72 { + tail = string([]rune(tail)[:72]) + "..." + } + return tail +} + +func telegramGenresValue(raw string) string { + raw = strings.TrimSpace(strings.Trim(raw, "[]")) + if raw == "" { + return "" + } + parts := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ',' || r == '/' || r == '|' || r == '、' + }) + if len(parts) <= 1 { + return raw + } + out := make([]string, 0, len(parts)) + seen := map[string]struct{}{} + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, ok := seen[part]; ok { + continue + } + seen[part] = struct{}{} + out = append(out, part) + } + return strings.Join(out, "、") +} + type telegramDataField struct { key string value string @@ -204,7 +679,9 @@ func telegramDisplayData(data map[string]interface{}) []telegramDataField { func telegramHiddenDataKey(key string) bool { switch strings.ToLower(strings.TrimSpace(key)) { - case "photo_url", "poster_url", "poster", "image_url", "backdrop_url": + case "photo_url", "poster_url", "poster", "image_url", "backdrop_url", + "tmdb_url", "imdb_url", "douban_url", "detail_url", "external_url", + "resource_title", "torrent_title", "release_title": return true default: return false @@ -215,6 +692,12 @@ func telegramFieldLabel(key string) string { switch strings.ToLower(strings.TrimSpace(key)) { case "title", "name": return "标题" + case "original_title": + return "原始片名" + case "original_language": + return "原始语言" + case "year", "release_year": + return "发行年份" case "save_path": return "保存路径" case "hash": @@ -222,7 +705,19 @@ func telegramFieldLabel(key string) string { case "media_type": return "媒体类型" case "media_category": - return "分类" + return "类别" + case "season_episode": + return "季集" + case "size", "bitrate": + return "大小" + case "version", "release_group": + return "版本" + case "rating": + return "评分" + case "genres": + return "类型" + case "overview": + return "简介" case "subscription": return "订阅" case "queued": @@ -232,6 +727,30 @@ func telegramFieldLabel(key string) string { } } +func telegramExternalLinks(data map[string]interface{}) string { + if len(data) == 0 { + return "" + } + links := []string{} + for _, item := range []struct { + key string + name string + }{ + {key: "tmdb_url", name: "TMDB"}, + {key: "imdb_url", name: "IMDB"}, + {key: "douban_url", name: "豆瓣"}, + } { + value := telegramDataString(data, item.key) + if isTelegramRemotePhotoURL(value) { + links = append(links, fmt.Sprintf(`%s`, escapeHTML(value), escapeHTML(item.name))) + } + } + if len(links) == 0 { + return "" + } + return "🔗 外链:" + strings.Join(links, " / ") +} + func telegramEventPhotoURL(event NotifyEvent) string { for _, key := range []string{"photo_url", "poster_url", "poster", "image_url", "backdrop_url"} { value := telegramDataString(event.Data, key) @@ -248,12 +767,41 @@ func telegramDataString(data map[string]interface{}, key string) string { } for k, value := range data { if strings.EqualFold(strings.TrimSpace(k), key) { - return strings.TrimSpace(fmt.Sprint(value)) + return telegramValueString(value) } } return "" } +func telegramValueString(value interface{}) string { + switch v := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(v) + case []string: + return strings.TrimSpace(strings.Join(v, ",")) + case []interface{}: + out := make([]string, 0, len(v)) + for _, item := range v { + if s := telegramValueString(item); s != "" { + out = append(out, s) + } + } + return strings.Join(out, ",") + case float32: + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.1f", v), "0"), ".") + case float64: + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.1f", v), "0"), ".") + default: + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" { + return "" + } + return text + } +} + func isTelegramRemotePhotoURL(raw string) bool { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/service/telegram_api.go b/internal/service/telegram_api.go index d1c591f..ef4226f 100644 --- a/internal/service/telegram_api.go +++ b/internal/service/telegram_api.go @@ -1,11 +1,13 @@ package service import ( + "bytes" "context" "encoding/json" "errors" "fmt" "io" + "mime/multipart" "net/http" "net/url" "os" @@ -120,6 +122,81 @@ func telegramPostJSON(ctx context.Context, cfg map[string]string, method string, return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, string(body), "application/json", timeout) } +func telegramPostMultipart(ctx context.Context, cfg map[string]string, method string, fields map[string]string, fileField, fileName string, file []byte, timeout time.Duration) error { + apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) + if err != nil { + return err + } + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for key, value := range fields { + if strings.TrimSpace(value) == "" { + continue + } + if err := writer.WriteField(key, value); err != nil { + _ = writer.Close() + return err + } + } + part, err := writer.CreateFormFile(fileField, fileName) + if err != nil { + _ = writer.Close() + return err + } + if _, err := part.Write(file); err != nil { + _ = writer.Close() + return err + } + if err := writer.Close(); err != nil { + return err + } + return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, body.String(), writer.FormDataContentType(), timeout) +} + +func telegramFetchRemotePhoto(ctx context.Context, cfg map[string]string, rawURL string, timeout time.Duration) ([]byte, string, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return nil, "", errors.New("telegram photo url required") + } + var lastErr error + for _, client := range telegramHTTPClients(timeout, cfg) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, "", err + } + req.Header.Set("User-Agent", "MediaStationGo/1.0") + resp, err := client.Do(req) + if err != nil { + lastErr = sanitizeTelegramError(err) + continue + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024+1)) + _ = resp.Body.Close() + if readErr != nil { + lastErr = readErr + continue + } + if resp.StatusCode >= 400 { + lastErr = fmt.Errorf("photo fetch error %d", resp.StatusCode) + continue + } + if len(body) == 0 { + lastErr = errors.New("photo fetch returned empty body") + continue + } + if len(body) > 10*1024*1024 { + lastErr = errors.New("photo too large") + continue + } + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + return body, contentType, nil + } + if lastErr != nil { + return nil, "", lastErr + } + return nil, "", errors.New("photo fetch failed") +} + func deleteTelegramWebhook(ctx context.Context, cfg map[string]string) error { payload := map[string]any{ "drop_pending_updates": false,