// Package service — Telegram 通知 Provider。 package service import ( "context" "fmt" "net/url" "sort" "strings" "time" ) // 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 != "" && len(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 } } 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 { 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") } message := strings.TrimSpace(event.Message) if message != "" { sb.WriteString("\n\n") sb.WriteString(formatTelegramBody(message)) } fields := telegramDisplayData(event.Data) if len(fields) > 0 { if message != "" { sb.WriteString("\n") } sb.WriteString("\n") for _, field := range fields { sb.WriteString(formatTelegramField(field.key, field.value)) sb.WriteString("\n") } } return strings.TrimSpace(sb.String()) } 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, 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 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", 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") } 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": return true default: return false } } func telegramFieldLabel(key string) string { switch strings.ToLower(strings.TrimSpace(key)) { case "title", "name": return "标题" case "save_path": return "保存路径" case "hash": return "Hash" case "media_type": return "媒体类型" case "media_category": return "分类" case "subscription": return "订阅" case "queued": return "新增资源" default: return strings.TrimSpace(key) } } 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 strings.TrimSpace(fmt.Sprint(value)) } } return "" } 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 != "" }