diff --git a/README.md b/README.md index 65c0ef0..617b3eb 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' # 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.24 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.25 MEDIASTATION_HTTP_PORT=18080 # 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。 @@ -307,7 +307,7 @@ vim docker-compose.yml # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.24 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.25 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -516,7 +516,7 @@ docker compose up -d ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.24 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.25 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -779,26 +779,26 @@ cd MediaStationGo | 平台 | 包名示例 | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.24-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.24-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.24-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.24-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.24-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.25-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.25-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.25-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.25-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.25-darwin-arm64.tar.gz` | 部署步骤: ```bash # Linux 示例 -tar -xzf MediaStationGo-v0.0.24-linux-amd64.tar.gz -cd MediaStationGo-v0.0.24-linux-amd64 +tar -xzf MediaStationGo-v0.0.25-linux-amd64.tar.gz +cd MediaStationGo-v0.0.25-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows: ```powershell -Expand-Archive .\MediaStationGo-v0.0.24-windows-amd64.zip -cd .\MediaStationGo-v0.0.24-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.25-windows-amd64.zip +cd .\MediaStationGo-v0.0.25-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/README_EN.md b/README_EN.md index ee438b4..8a1862e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -235,7 +235,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.24 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.25 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.24 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.25 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -593,25 +593,25 @@ Each release provides multi-platform archives: | Platform | Package example | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.24-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.24-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.24-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.24-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.24-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.25-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.25-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.25-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.25-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.25-darwin-arm64.tar.gz` | Linux example: ```bash -tar -xzf MediaStationGo-v0.0.24-linux-amd64.tar.gz -cd MediaStationGo-v0.0.24-linux-amd64 +tar -xzf MediaStationGo-v0.0.25-linux-amd64.tar.gz +cd MediaStationGo-v0.0.25-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows example: ```powershell -Expand-Archive .\MediaStationGo-v0.0.24-windows-amd64.zip -cd .\MediaStationGo-v0.0.24-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.25-windows-amd64.zip +cd .\MediaStationGo-v0.0.25-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/docker-compose.yml b/docker-compose.yml index 33ddbc8..1e4f85c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.24 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.25 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go index 63e79d5..d1b42f5 100644 --- a/internal/service/notify_channels.go +++ b/internal/service/notify_channels.go @@ -346,11 +346,15 @@ func normalizeChannelInput(in *ChannelInput) { in.Config = map[string]any{} } chatID := str(in.Config["chat_id"]) - if chatID == "" || !strings.HasPrefix(chatID, "-") { + if chatID == "" { return } - if str(in.Config["group_chat_id"]) == "" && str(in.Config["channel_chat_id"]) == "" && str(in.Config["command_chat_id"]) == "" { + if strings.HasPrefix(chatID, "-") && str(in.Config["group_chat_id"]) == "" && str(in.Config["channel_chat_id"]) == "" && str(in.Config["command_chat_id"]) == "" { in.Config["group_chat_id"] = chatID + return + } + if !strings.HasPrefix(chatID, "-") && str(in.Config["admin_user_ids"]) == "" { + in.Config["admin_user_ids"] = chatID } } @@ -369,6 +373,8 @@ func telegramTargetChatIDs(cfg map[string]string) []string { chatID := strings.TrimSpace(cfg["chat_id"]) if strings.HasPrefix(chatID, "-") { targets = append(targets, chatID) + } else if chatID != "" && strings.TrimSpace(cfg["admin_user_ids"]) == "" { + targets = append(targets, chatID) } } if len(targets) == 0 { diff --git a/internal/service/telegram_api.go b/internal/service/telegram_api.go index f7fe595..170af6c 100644 --- a/internal/service/telegram_api.go +++ b/internal/service/telegram_api.go @@ -46,17 +46,58 @@ func telegramMethodURL(cfg map[string]string, botToken, method string) (string, } func telegramHTTPClient(timeout time.Duration, cfg map[string]string) *http.Client { - transport := NewExternalTransport() - proxyRaw := strings.TrimSpace(cfg["proxy_url"]) - if proxyRaw == "" { - proxyRaw = strings.TrimSpace(os.Getenv("MEDIASTATION_TELEGRAM_PROXY_URL")) + clients := telegramHTTPClients(timeout, cfg) + return clients[0] +} + +func telegramHTTPClients(timeout time.Duration, cfg map[string]string) []*http.Client { + clients := []*http.Client{} + seen := map[string]bool{} + for _, proxyRaw := range telegramProxyCandidates(cfg) { + proxyURL, err := normalizeProxyURL(proxyRaw, "http") + if err != nil || proxyURL == nil { + continue + } + key := proxyURL.String() + if seen[key] { + continue + } + seen[key] = true + transport := NewExternalTransport() + transport.Proxy = http.ProxyURL(proxyURL) + clients = append(clients, &http.Client{Timeout: timeout, Transport: transport}) } - if proxyRaw != "" { - if proxyURL, err := normalizeProxyURL(proxyRaw, "http"); err == nil { - transport.Proxy = http.ProxyURL(proxyURL) + transport := NewExternalTransport() + clients = append(clients, &http.Client{Timeout: timeout, Transport: transport}) + return clients +} + +func telegramProxyCandidates(cfg map[string]string) []string { + out := []string{} + for _, value := range []string{ + cfg["proxy_url"], + os.Getenv("MEDIASTATION_TELEGRAM_PROXY_URL"), + } { + if strings.TrimSpace(value) != "" { + out = append(out, value) } } - return &http.Client{Timeout: timeout, Transport: transport} + if len(out) > 0 { + return out + } + for _, value := range []string{ + "http://127.0.0.1:10808", + "http://127.0.0.1:10809", + "http://127.0.0.1:7890", + "http://127.0.0.1:7891", + "http://host.docker.internal:7890", + "http://host.docker.internal:10808", + "http://172.17.0.1:7890", + "http://172.17.0.1:10808", + } { + out = append(out, value) + } + return out } func telegramPostForm(ctx context.Context, cfg map[string]string, method string, form url.Values, timeout time.Duration) error { @@ -64,12 +105,7 @@ func telegramPostForm(ctx context.Context, cfg map[string]string, method string, if err != nil { return err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return telegramDo(telegramHTTPClient(timeout, cfg), req) + return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, form.Encode(), "application/x-www-form-urlencoded", timeout) } func telegramPostJSON(ctx context.Context, cfg map[string]string, method string, payload any, timeout time.Duration) error { @@ -81,12 +117,7 @@ func telegramPostJSON(ctx context.Context, cfg map[string]string, method string, if err != nil { return err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(body))) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - return telegramDo(telegramHTTPClient(timeout, cfg), req) + return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, string(body), "application/json", timeout) } func telegramDo(client *http.Client, req *http.Request) error { @@ -102,11 +133,72 @@ func telegramDo(client *http.Client, req *http.Request) error { return nil } +func telegramDoWithFallback(ctx context.Context, cfg map[string]string, method, apiURL, body, contentType string, timeout time.Duration) error { + var lastErr error + for _, client := range telegramHTTPClients(timeout, cfg) { + req, err := http.NewRequestWithContext(ctx, method, apiURL, strings.NewReader(body)) + if err != nil { + return err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if err := telegramDo(client, req); err != nil { + lastErr = err + continue + } + return nil + } + if lastErr != nil { + return lastErr + } + return errors.New("telegram request failed") +} + +func telegramPostJSONDecode(ctx context.Context, cfg map[string]string, method string, payload any, timeout time.Duration, out any) error { + apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) + if err != nil { + return err + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + var lastErr error + for _, client := range telegramHTTPClients(timeout, cfg) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(body))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + lastErr = sanitizeTelegramError(err) + continue + } + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + if resp.StatusCode >= 400 { + lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody))) + continue + } + if out != nil { + return json.Unmarshal(respBody, out) + } + return nil + } + if lastErr != nil { + return lastErr + } + return errors.New("telegram request failed") +} + func telegramStringConfigFromAny(cfg map[string]any) map[string]string { out := make(map[string]string, len(cfg)) for key, value := range cfg { out[key] = str(value) } + normalizeTelegramConfig(out) return out } diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go index c6911cd..cb8966f 100644 --- a/internal/service/telegram_api_test.go +++ b/internal/service/telegram_api_test.go @@ -69,3 +69,30 @@ func TestNormalizeTelegramChannelMigratesLegacyChatID(t *testing.T) { t.Fatalf("group_chat_id = %q, want -10001", got) } } + +func TestNormalizeTelegramChannelMigratesLegacyPrivateChatIDToAdmin(t *testing.T) { + cfg := map[string]string{"chat_id": "5812333517"} + normalizeTelegramConfig(cfg) + if got := cfg["admin_user_ids"]; got != "5812333517" { + t.Fatalf("admin_user_ids = %q, want legacy chat_id", got) + } +} + +func TestTelegramTargetChatIDsUsesLegacyPrivateChatID(t *testing.T) { + got := telegramTargetChatIDs(map[string]string{ + "chat_id": "5812333517", + }) + if len(got) != 1 || got[0] != "5812333517" { + t.Fatalf("got %#v, want legacy private chat target", got) + } +} + +func TestTelegramProxyCandidatesDefaultLocalFallbacks(t *testing.T) { + got := telegramProxyCandidates(map[string]string{}) + joined := strings.Join(got, ",") + for _, want := range []string{"127.0.0.1:10808", "172.17.0.1:7890"} { + if !strings.Contains(joined, want) { + t.Fatalf("default proxy candidates %q missing %q", joined, want) + } + } +} diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 5a9af90..859fff6 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -7,6 +7,7 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -501,7 +502,7 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, cfg map[string]string s.log.Warn("telegram polling config invalid", zap.Error(err)) return } - client := telegramHTTPClient(45*time.Second, cfg) + clients := telegramHTTPClients(45*time.Second, cfg) for { select { @@ -514,21 +515,12 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, cfg map[string]string "offset": offset, "timeout": 30, }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, pollURL, strings.NewReader(string(reqBody))) + respBody, err := telegramPollingRequest(ctx, clients, pollURL, string(reqBody)) if err != nil { + s.log.Debug("telegram polling failed", zap.Error(err)) time.Sleep(5 * time.Second) continue } - req.Header.Set("Content-Type", "application/json") - - resp, err := client.Do(req) - if err != nil { - s.log.Debug("telegram polling failed", zap.Error(sanitizeTelegramError(err))) - time.Sleep(5 * time.Second) - continue - } - respBody, _ := io.ReadAll(resp.Body) - resp.Body.Close() var result struct { OK bool `json:"ok"` @@ -554,6 +546,33 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, cfg map[string]string } } +func telegramPollingRequest(ctx context.Context, clients []*http.Client, pollURL, body string) ([]byte, error) { + var lastErr error + for _, client := range clients { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, pollURL, strings.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + lastErr = sanitizeTelegramError(err) + continue + } + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode >= 400 { + lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody))) + continue + } + return respBody, nil + } + if lastErr != nil { + return nil, lastErr + } + return nil, errors.New("telegram polling failed") +} + // ── Message Sending ── // reply 通过 Telegram Bot API 发送回复消息。 @@ -651,11 +670,14 @@ func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCal if cb == nil || cb.Message == nil { return nil } - channel := s.findChannelByChatID(ctx, cb.Message.Chat.ID) + msg := *cb.Message + msg.From = cb.From + channel := s.findChannelForMessage(ctx, &msg) + if channel == nil { + channel = s.findChannelByChatID(ctx, cb.Message.Chat.ID) + } switch strings.TrimSpace(cb.Data) { case "adult_toggle": - msg := *cb.Message - msg.From = cb.From reply := s.cmdHideAdult(ctx, &msg, nil) if reply.Text != "" { return s.reply(ctx, channel, cb.Message.Chat.ID, reply) @@ -745,29 +767,17 @@ func (s *TelegramBotService) telegramUserIsChatMember(ctx context.Context, chann "chat_id": chatID, "user_id": telegramUserID, } - apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], "getChatMember") - if err != nil { - return false - } - body, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(body))) - if err != nil { - return false - } - req.Header.Set("Content-Type", "application/json") - resp, err := telegramHTTPClient(8*time.Second, cfg).Do(req) - if err != nil { - s.log.Warn("telegram getChatMember failed", zap.String("chat_id", chatID), zap.Int("telegram_user_id", telegramUserID), zap.Error(sanitizeTelegramError(err))) - return false - } - defer resp.Body.Close() var result struct { OK bool `json:"ok"` Result struct { Status string `json:"status"` } `json:"result"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || !result.OK { + if err := telegramPostJSONDecode(ctx, cfg, "getChatMember", payload, 8*time.Second, &result); err != nil { + s.log.Warn("telegram getChatMember failed", zap.String("chat_id", chatID), zap.Int("telegram_user_id", telegramUserID), zap.Error(sanitizeTelegramError(err))) + return false + } + if !result.OK { return false } switch strings.ToLower(result.Result.Status) { @@ -784,13 +794,14 @@ func (s *TelegramBotService) telegramUserIDConfigured(channel *model.NotifyChann } cfg := s.telegramChannelConfig(channel) target := strconv.Itoa(telegramUserID) - for _, value := range strings.FieldsFunc(cfg["admin_user_ids"], func(r rune) bool { - return r == ',' || r == ';' || r == ',' || r == ' ' || r == '\n' || r == '\t' - }) { - if strings.TrimSpace(value) == target { + for _, value := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) { + if value == target { return true } } + if strings.TrimSpace(cfg["admin_user_ids"]) == "" && strings.TrimSpace(cfg["chat_id"]) == target { + return true + } return false } @@ -810,9 +821,29 @@ func telegramConfigFromChannel(crypto *CryptoService, channel *model.NotifyChann if err := json.Unmarshal([]byte(configStr), &cfg); err != nil || cfg == nil { return map[string]string{} } + normalizeTelegramConfig(cfg) return cfg } +func normalizeTelegramConfig(cfg map[string]string) { + if cfg == nil { + return + } + chatID := strings.TrimSpace(cfg["chat_id"]) + if chatID == "" { + return + } + if strings.HasPrefix(chatID, "-") { + if strings.TrimSpace(cfg["group_chat_id"]) == "" && strings.TrimSpace(cfg["channel_chat_id"]) == "" && strings.TrimSpace(cfg["command_chat_id"]) == "" { + cfg["group_chat_id"] = chatID + } + return + } + if strings.TrimSpace(cfg["admin_user_ids"]) == "" { + cfg["admin_user_ids"] = chatID + } +} + func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *TelegramMessage, userID string) error { name := strings.TrimSpace(msg.From.FirstName) if msg.From.Username != "" { diff --git a/web/src/pages/NotifyChannelsPage.tsx b/web/src/pages/NotifyChannelsPage.tsx index 241cfbb..8811b82 100644 --- a/web/src/pages/NotifyChannelsPage.tsx +++ b/web/src/pages/NotifyChannelsPage.tsx @@ -222,6 +222,9 @@ function normalizeInitialConfig(type: NotifyChannel['type'], raw: Record