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,