From b35b36738e5253ea5a6ff7e550aeb3ed23c021ec Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Sun, 7 Jun 2026 13:58:24 +0800 Subject: [PATCH] fix(telegram): honor proxy fallbacks for bot api --- .env.example | 9 +++ docker-compose.yml | 13 +++- internal/service/telegram_api.go | 106 ++++++++++++++++++++++---- internal/service/telegram_api_test.go | 36 ++++++++- internal/service/telegram_bot.go | 38 ++------- 5 files changed, 152 insertions(+), 50 deletions(-) diff --git a/.env.example b/.env.example index e314010..b9154ba 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,12 @@ ADMIN_INITIAL_PASSWORD=admin123 # MEDIASTATION_TRANSCODER_THREADS=2 # MEDIASTATION_TRANSCODER_MAX_CONCURRENT=1 # MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS=120 + +# Telegram Bot / notify outbound network. +# Leave empty if direct network or v2rayA redirect already covers Docker bridge traffic. +# MEDIASTATION_TELEGRAM_API_BASE_URL=https://api.telegram.org +# MEDIASTATION_TELEGRAM_PROXY_URL=http://host.docker.internal:20171 +# HTTP_PROXY= +# HTTPS_PROXY= +# ALL_PROXY= +# NO_PROXY=127.0.0.1,localhost diff --git a/docker-compose.yml b/docker-compose.yml index 41735cc..56ea865 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -142,11 +142,20 @@ services: MEDIASTATION_APP_CORS_ORIGINS: ${MEDIASTATION_APP_CORS_ORIGINS:-} # Telegram 通知/Bot 出站网络。通常留空即可;如 NAS 访问 Telegram 超时, - # 可在 .env 中填写反代 API 或代理地址: + # 可在 .env 中填写反代 API 或代理地址。v2rayA 常见代理: + # HTTP http://host.docker.internal:20171 或 http://172.17.0.1:20171 + # SOCKS socks5://host.docker.internal:20170 或 socks5://172.17.0.1:20170 # MEDIASTATION_TELEGRAM_API_BASE_URL=https://api.telegram.org - # MEDIASTATION_TELEGRAM_PROXY_URL=http://172.17.0.1:7890 + # MEDIASTATION_TELEGRAM_PROXY_URL=http://host.docker.internal:20171 MEDIASTATION_TELEGRAM_API_BASE_URL: ${MEDIASTATION_TELEGRAM_API_BASE_URL:-} MEDIASTATION_TELEGRAM_PROXY_URL: ${MEDIASTATION_TELEGRAM_PROXY_URL:-} + # 可选:把宿主机 shell/.env 中的代理变量透传给容器。留空无效果。 + # 如果 NAS 已使用 v2rayA redirect/透明代理且 Docker 网桥流量已被接管, + # 通常不需要填写这些变量。 + HTTP_PROXY: ${HTTP_PROXY:-} + HTTPS_PROXY: ${HTTPS_PROXY:-} + ALL_PROXY: ${ALL_PROXY:-} + NO_PROXY: ${NO_PROXY:-127.0.0.1,localhost} # 宿主机文件权限映射。Linux/NAS 常用 1000:1000,可用 id 命令查看。 PUID: ${PUID:-1000} diff --git a/internal/service/telegram_api.go b/internal/service/telegram_api.go index 170af6c..f7c8ce2 100644 --- a/internal/service/telegram_api.go +++ b/internal/service/telegram_api.go @@ -53,26 +53,45 @@ func telegramHTTPClient(timeout time.Duration, cfg map[string]string) *http.Clie 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 + + addProxy := func(proxyURL *url.URL) { + if proxyURL == nil { + return } key := proxyURL.String() if seen[key] { - continue + return } seen[key] = true transport := NewExternalTransport() transport.Proxy = http.ProxyURL(proxyURL) clients = append(clients, &http.Client{Timeout: timeout, Transport: transport}) } + + for _, proxyRaw := range telegramExplicitProxyCandidates(cfg) { + proxyURL, err := normalizeProxyURL(proxyRaw, "http") + if err != nil || proxyURL == nil { + continue + } + addProxy(proxyURL) + } + if proxyURL, err := telegramAutoProxyURL(cfg); err == nil { + addProxy(proxyURL) + } + for _, proxyRaw := range telegramFallbackProxyCandidates() { + proxyURL, err := normalizeProxyURL(proxyRaw, "http") + if err != nil || proxyURL == nil { + continue + } + addProxy(proxyURL) + } transport := NewExternalTransport() + transport.Proxy = nil clients = append(clients, &http.Client{Timeout: timeout, Transport: transport}) return clients } -func telegramProxyCandidates(cfg map[string]string) []string { +func telegramExplicitProxyCandidates(cfg map[string]string) []string { out := []string{} for _, value := range []string{ cfg["proxy_url"], @@ -82,22 +101,48 @@ func telegramProxyCandidates(cfg map[string]string) []string { out = append(out, value) } } + return out +} + +func telegramProxyCandidates(cfg map[string]string) []string { + out := telegramExplicitProxyCandidates(cfg) 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", + return telegramFallbackProxyCandidates() +} + +func telegramFallbackProxyCandidates() []string { + // Common local proxy ports used by Clash / v2rayN / v2rayA. Docker on Linux + // reaches host services through host.docker.internal (when extra_hosts is + // configured) or the bridge gateway 172.17.0.1. These are only fallbacks: + // explicit channel/env proxy and environment/system proxy are tried first. + return []string{ + "http://host.docker.internal:20171", + "socks5://host.docker.internal:20170", + "http://172.17.0.1:20171", + "socks5://172.17.0.1:20170", "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) + "http://127.0.0.1:10808", + "http://127.0.0.1:10809", + "http://127.0.0.1:7890", + "http://127.0.0.1:7891", } - return out +} + +func telegramAutoProxyURL(cfg map[string]string) (*url.URL, error) { + base := telegramAPIBaseURL(cfg) + if _, err := url.ParseRequestURI(base); err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, base, nil) + if err != nil { + return nil, err + } + return ProxyFromEnvironmentOrSystem(req) } func telegramPostForm(ctx context.Context, cfg map[string]string, method string, form url.Values, timeout time.Duration) error { @@ -193,6 +238,39 @@ func telegramPostJSONDecode(ctx context.Context, cfg map[string]string, method s return errors.New("telegram request failed") } +func telegramGetJSONDecode(ctx context.Context, cfg map[string]string, method string, timeout time.Duration, out any) error { + apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) + if err != nil { + return err + } + var lastErr error + for _, client := range telegramHTTPClients(timeout, cfg) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return err + } + 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 { diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go index d900695..aa47179 100644 --- a/internal/service/telegram_api_test.go +++ b/internal/service/telegram_api_test.go @@ -2,8 +2,10 @@ package service import ( "errors" + "net/http" "strings" "testing" + "time" ) func TestTelegramMethodURLUsesCustomAPIBase(t *testing.T) { @@ -90,13 +92,45 @@ func TestTelegramTargetChatIDsUsesLegacyPrivateChatID(t *testing.T) { 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"} { + for _, want := range []string{"host.docker.internal:20171", "socks5://172.17.0.1:20170", "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) } } } +func TestTelegramHTTPClientsPreferConfiguredProxy(t *testing.T) { + clients := telegramHTTPClients(time.Second, map[string]string{ + "proxy_url": "http://proxy.example:7890", + }) + if len(clients) == 0 { + t.Fatal("expected telegram clients") + } + if got := telegramClientProxyString(t, clients[0]); got != "http://proxy.example:7890" { + t.Fatalf("first client proxy = %q, want configured proxy", got) + } +} + +func telegramClientProxyString(t *testing.T, client *http.Client) string { + t.Helper() + transport, ok := client.Transport.(*http.Transport) + if !ok || transport.Proxy == nil { + return "" + } + req, err := http.NewRequest(http.MethodGet, defaultTelegramAPIBaseURL, nil) + if err != nil { + t.Fatal(err) + } + proxyURL, err := transport.Proxy(req) + if err != nil { + t.Fatal(err) + } + if proxyURL == nil { + return "" + } + return proxyURL.String() +} + func TestTelegramCommandFiltering(t *testing.T) { if telegramIsCommandText("今天看什么") { t.Fatal("plain chat message should not be treated as command") diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 8c19f70..5da5f25 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -1296,49 +1296,21 @@ func userNameOrFallback(user *model.User) string { // SetWebhook 注册 Telegram Bot Webhook URL。 func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookURL string) error { - payload, _ := json.Marshal(map[string]interface{}{ + payload := map[string]interface{}{ "url": webhookURL, "allowed_updates": []string{"message", "callback_query"}, - }) + } cfg := map[string]string{"bot_token": botToken} - apiURL, err := telegramMethodURL(cfg, botToken, "setWebhook") - if err != nil { - return err - } - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(payload))) - req.Header.Set("Content-Type", "application/json") - - client := telegramHTTPClient(15*time.Second, cfg) - resp, err := client.Do(req) - if err != nil { - return sanitizeTelegramError(err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("setWebhook failed: %s", sanitizeTelegramText(string(body))) - } - return nil + return telegramPostJSON(ctx, cfg, "setWebhook", payload, 15*time.Second) } // GetWebhookInfo 获取 Webhook 配置信息。 func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string) (map[string]interface{}, error) { cfg := map[string]string{"bot_token": botToken} - apiURL, err := telegramMethodURL(cfg, botToken, "getWebhookInfo") - if err != nil { + var result map[string]interface{} + if err := telegramGetJSONDecode(ctx, cfg, "getWebhookInfo", 10*time.Second, &result); err != nil { return nil, err } - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) - resp, err := telegramHTTPClient(10*time.Second, cfg).Do(req) - if err != nil { - return nil, sanitizeTelegramError(err) - } - defer resp.Body.Close() - - var result map[string]interface{} - body, _ := io.ReadAll(resp.Body) - json.Unmarshal(body, &result) return result, nil }