// Package service — Telegram 通知 Provider。 package service import ( "context" "fmt" "net/url" "regexp" "sort" "strconv" "strings" "time" "unicode/utf8" ) // TelegramProvider 通过 Telegram Bot API 发送通知。 type TelegramProvider struct{} // Send 发送 Telegram 消息。 func (p *TelegramProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error { botToken := cfg["bot_token"] chatIDs := telegramTargetChatIDs(cfg) parseMode := cfg["parse_mode"] if parseMode == "" { parseMode = "HTML" } if botToken == "" || len(chatIDs) == 0 { return fmt.Errorf("telegram: bot_token and group_chat_id/channel_chat_id are required") } text := formatTelegramMessage(event, parseMode) photoURL := telegramEventPhotoURL(event) var firstErr error for _, chatID := range chatIDs { if photoURL != "" && utf8.RuneCountInString(text) <= 1024 { payload := map[string]string{ "chat_id": chatID, "photo": photoURL, "caption": text, "parse_mode": parseMode, } if err := telegramPostJSON(ctx, cfg, "sendPhoto", payload, 15*time.Second); err == nil { continue } 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, "text": text, "parse_mode": parseMode, } if err := telegramPostJSON(ctx, cfg, "sendMessage", payload, 15*time.Second); err != nil && firstErr == nil { firstErr = err } } return firstErr } // ValidateConfig 验证 Telegram 配置。 func (p *TelegramProvider) ValidateConfig(cfg map[string]string) error { if cfg["bot_token"] == "" { return fmt.Errorf("telegram: bot_token is required") } if len(telegramTargetChatIDs(cfg)) == 0 { return fmt.Errorf("telegram: group_chat_id or channel_chat_id is required") } return nil } // formatTelegramMessage 格式化消息内容。 func formatTelegramMessage(event NotifyEvent, parseMode string) string { text := formatTelegramNotification(event) if parseMode == "HTML" || parseMode == "" { return text } result := text result = strings.ReplaceAll(result, "", "**") result = strings.ReplaceAll(result, "", "**") result = strings.ReplaceAll(result, "", "`") result = strings.ReplaceAll(result, "", "`") result = strings.ReplaceAll(result, "<", "<") result = strings.ReplaceAll(result, ">", ">") result = strings.ReplaceAll(result, "&", "&") return result } func formatTelegramNotification(event NotifyEvent) string { if text := formatTelegramMediaNotification(event); text != "" { return text } var sb strings.Builder 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 != "" { if sb.Len() > 0 { sb.WriteString("\n\n") } sb.WriteString(formatTelegramBody(message)) } fields := telegramDisplayData(event.Data) if len(fields) > 0 { if sb.Len() > 0 { sb.WriteString("\n") } sb.WriteString("\n") for _, field := range fields { sb.WriteString(formatTelegramField(field.key, field.value)) 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)) for _, line := range lines { line = strings.TrimSpace(line) if line == "" { out = append(out, "") continue } if strings.HasPrefix(line, "- ") { 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 } out = append(out, escapeHTML(line)) } 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(":") if idx < 0 { idx = strings.Index(line, ":") sepLen = len(":") } if idx <= 0 { return "", "", false } key := strings.TrimSpace(line[:idx]) value := strings.TrimSpace(line[idx+sepLen:]) if key == "" || value == "" || len([]rune(key)) > 16 { return "", "", false } return key, value, true } func formatTelegramField(key, value string) string { key = telegramFieldLabel(key) escapedValue := escapeHTML(strings.TrimSpace(value)) if telegramCodeField(key) { escapedValue = "" + escapedValue + "" } return fmt.Sprintf("%s %s:%s", telegramFieldIcon(key), escapeHTML(key), escapedValue) } func telegramCodeField(key string) bool { key = strings.ToLower(strings.TrimSpace(key)) return strings.Contains(key, "hash") || strings.Contains(key, "路径") || strings.Contains(key, "path") || 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 } func telegramDisplayData(data map[string]interface{}) []telegramDataField { if len(data) == 0 { return nil } keys := make([]string, 0, len(data)) for key := range data { if telegramHiddenDataKey(key) { continue } keys = append(keys, key) } sort.Strings(keys) fields := make([]telegramDataField, 0, len(keys)) for _, key := range keys { value := strings.TrimSpace(fmt.Sprint(data[key])) if value == "" || value == "" { continue } fields = append(fields, telegramDataField{key: key, value: value}) } return fields } func telegramHiddenDataKey(key string) bool { switch strings.ToLower(strings.TrimSpace(key)) { 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 } } 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": return "Hash" case "media_type": return "媒体类型" case "media_category": 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": return "新增资源" default: return strings.TrimSpace(key) } } 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) if isTelegramRemotePhotoURL(value) { return value } } return "" } func telegramDataString(data map[string]interface{}, key string) string { if len(data) == 0 { return "" } for k, value := range data { if strings.EqualFold(strings.TrimSpace(k), key) { 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 == "" { return false } u, err := url.Parse(raw) return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" }