diff --git a/README.md b/README.md index cb4e8be..8eb2029 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.22 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.23 MEDIASTATION_HTTP_PORT=18080 # 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。 @@ -307,7 +307,7 @@ vim docker-compose.yml # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.23 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -516,7 +516,7 @@ docker compose up -d ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.23 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -779,26 +779,26 @@ cd MediaStationGo | 平台 | 包名示例 | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.22-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.22-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.22-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.22-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.22-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.23-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.23-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.23-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.23-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.23-darwin-arm64.tar.gz` | 部署步骤: ```bash # Linux 示例 -tar -xzf MediaStationGo-v0.0.22-linux-amd64.tar.gz -cd MediaStationGo-v0.0.22-linux-amd64 +tar -xzf MediaStationGo-v0.0.23-linux-amd64.tar.gz +cd MediaStationGo-v0.0.23-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows: ```powershell -Expand-Archive .\MediaStationGo-v0.0.22-windows-amd64.zip -cd .\MediaStationGo-v0.0.22-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.23-windows-amd64.zip +cd .\MediaStationGo-v0.0.23-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` @@ -905,13 +905,15 @@ MediaStationGo/ | `MEDIASTATION_CACHE_CACHE_DIR` | `./cache` | 图片/转码缓存目录 | | `MEDIASTATION_SECRETS_JWT_SECRET` | 自动生成 | JWT 和敏感配置加密种子 | | `MEDIASTATION_APP_CORS_ORIGINS` | 空 | 额外允许的跨域来源 | +| `MEDIASTATION_TELEGRAM_API_BASE_URL` | `https://api.telegram.org` | Telegram Bot API 地址;网络受限时可填写反代地址 | +| `MEDIASTATION_TELEGRAM_PROXY_URL` | 空 | Telegram 出站代理,例如 `http://172.17.0.1:7890` 或 `socks5://172.17.0.1:1080` | 后台可运行时配置: - API Key:TMDb、Bangumi、TheTVDB、Fanart、OpenAI Compatible 等。 - 站点:M-Team、NexusPHP、Unit3D、自定义 RSS 等。 - 下载器:qBittorrent、Transmission、Aria2。 -- 通知渠道:Telegram、Bark、Webhook、Email 等。 +- 通知渠道:Telegram、Bark、Webhook、Email 等。Telegram 渠道支持单独配置 API 反代与代理地址,测试通知失败时错误信息会自动隐藏 Bot Token。 - 播放配置、权限配置、调度任务、存储配置。 --- diff --git a/README_EN.md b/README_EN.md index 6af14fd..582d72d 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.22 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.23 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.22 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.23 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.22-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.22-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.22-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.22-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.22-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.23-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.23-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.23-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.23-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.23-darwin-arm64.tar.gz` | Linux example: ```bash -tar -xzf MediaStationGo-v0.0.22-linux-amd64.tar.gz -cd MediaStationGo-v0.0.22-linux-amd64 +tar -xzf MediaStationGo-v0.0.23-linux-amd64.tar.gz +cd MediaStationGo-v0.0.23-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows example: ```powershell -Expand-Archive .\MediaStationGo-v0.0.22-windows-amd64.zip -cd .\MediaStationGo-v0.0.22-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.23-windows-amd64.zip +cd .\MediaStationGo-v0.0.23-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` @@ -718,13 +718,15 @@ Common variables: | `MEDIASTATION_CACHE_CACHE_DIR` | `./cache` | Image/transcode cache | | `MEDIASTATION_SECRETS_JWT_SECRET` | Auto-generated | JWT and encrypted settings seed | | `MEDIASTATION_APP_CORS_ORIGINS` | empty | Extra CORS origins | +| `MEDIASTATION_TELEGRAM_API_BASE_URL` | `https://api.telegram.org` | Telegram Bot API base URL; use a reverse proxy when Telegram is blocked or slow | +| `MEDIASTATION_TELEGRAM_PROXY_URL` | empty | Telegram outbound proxy, e.g. `http://172.17.0.1:7890` or `socks5://172.17.0.1:1080` | Runtime settings from the admin UI: - API keys: TMDb, Bangumi, TheTVDB, Fanart, OpenAI Compatible. - Sites: M-Team, NexusPHP, Unit3D, custom RSS. - Download clients: qBittorrent, Transmission, Aria2. -- Notifications: Telegram, Bark, Webhook, Email. +- Notifications: Telegram, Bark, Webhook, Email. Telegram channels can use a per-channel API base URL or proxy, and test errors redact Bot Tokens automatically. - Playback profiles, permissions, scheduler tasks, storage settings. --- diff --git a/docker-compose.yml b/docker-compose.yml index ca552ac..ec66800 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.23 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -141,6 +141,13 @@ services: # 跨域来源。通常无需设置;反向代理或三端客户端异常时再按需填写。 MEDIASTATION_APP_CORS_ORIGINS: ${MEDIASTATION_APP_CORS_ORIGINS:-} + # Telegram 通知/Bot 出站网络。通常留空即可;如 NAS 访问 Telegram 超时, + # 可在 .env 中填写反代 API 或代理地址: + # MEDIASTATION_TELEGRAM_API_BASE_URL=https://api.telegram.org + # MEDIASTATION_TELEGRAM_PROXY_URL=http://172.17.0.1:7890 + MEDIASTATION_TELEGRAM_API_BASE_URL: ${MEDIASTATION_TELEGRAM_API_BASE_URL:-} + MEDIASTATION_TELEGRAM_PROXY_URL: ${MEDIASTATION_TELEGRAM_PROXY_URL:-} + # 宿主机文件权限映射。Linux/NAS 常用 1000:1000,可用 id 命令查看。 PUID: ${PUID:-1000} PGID: ${PGID:-1000} diff --git a/internal/service/notifier.go b/internal/service/notifier.go index 0c5f086..b93e2a0 100644 --- a/internal/service/notifier.go +++ b/internal/service/notifier.go @@ -3,10 +3,10 @@ // NotifierService dispatches structured messages to one or more channels // configured in the system settings table: // -// notify.telegram.bot_token + notify.telegram.chat_id -// notify.bark.server + notify.bark.key -// notify.wechat.sendkey -// notify.webhook.url + notify.webhook.method +// notify.telegram.bot_token + notify.telegram.chat_id +// notify.bark.server + notify.bark.key +// notify.wechat.sendkey +// notify.webhook.url + notify.webhook.method // // Notifications are triggered by the subscription poller, the download // poller, the scan / scrape completions, and any future event worth @@ -38,7 +38,7 @@ func NewNotifierService(log *zap.Logger, repo *repository.Container) *NotifierSe return &NotifierService{ log: log, repo: repo, - client: &http.Client{Timeout: 10 * time.Second}, + client: NewExternalHTTPClient(10 * time.Second), } } @@ -63,17 +63,20 @@ func (n *NotifierService) sendTelegram(ctx context.Context, title, body string) return } text := fmt.Sprintf("%s\n\n%s", escapeHTML(title), escapeHTML(body)) - u := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token) form := url.Values{} form.Set("chat_id", chatID) form.Set("text", text) form.Set("parse_mode", "HTML") - resp, err := n.client.PostForm(u, form) - if err != nil { - n.log.Debug("telegram notify failed", zap.Error(err)) - return + cfg := map[string]string{"bot_token": token} + if apiBase := n.get(ctx, "notify.telegram.api_base_url"); apiBase != "" { + cfg["api_base_url"] = apiBase + } + if proxyURL := n.get(ctx, "notify.telegram.proxy_url"); proxyURL != "" { + cfg["proxy_url"] = proxyURL + } + if err := telegramPostForm(ctx, cfg, "sendMessage", form, 15*time.Second); err != nil { + n.log.Debug("telegram notify failed", zap.Error(err)) } - defer resp.Body.Close() } func (n *NotifierService) sendBark(ctx context.Context, title, body string) { diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go index a450f88..03a2810 100644 --- a/internal/service/notify_channels.go +++ b/internal/service/notify_channels.go @@ -34,7 +34,7 @@ func NewNotifyChannelService(log *zap.Logger, repo *repository.Container) *Notif return &NotifyChannelService{ log: log, repo: repo, - client: &http.Client{Timeout: 10 * time.Second}, + client: NewExternalHTTPClient(10 * time.Second), } } @@ -89,6 +89,7 @@ func (s *NotifyChannelService) List(ctx context.Context) ([]channelView, error) // Create persists a new channel. func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*channelView, error) { + normalizeChannelInput(&in) if err := validateChannel(in); err != nil { return nil, err } @@ -113,6 +114,7 @@ func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*ch // Update applies a partial patch to an existing channel. func (s *NotifyChannelService) Update(ctx context.Context, id string, in ChannelInput) (*channelView, error) { + normalizeChannelInput(&in) if err := validateChannel(in); err != nil { return nil, err } @@ -218,20 +220,18 @@ func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyCh switch n.Type { case "telegram": - token := str(cfg["bot_token"]) - chat := str(cfg["chat_id"]) + telegramCfg := telegramStringConfigFromAny(cfg) + token := telegramCfg["bot_token"] + chat := telegramCfg["chat_id"] if token == "" || chat == "" { return errors.New("telegram missing bot_token / chat_id") } text := fmt.Sprintf("%s\n\n%s", escapeHTML(title), escapeHTML(body)) - u := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token) form := url.Values{} form.Set("chat_id", chat) form.Set("text", text) form.Set("parse_mode", "HTML") - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return s.do(req) + return telegramPostForm(ctx, telegramCfg, "sendMessage", form, 15*time.Second) case "bark": key := str(cfg["device_key"]) @@ -332,12 +332,28 @@ func validateChannel(in ChannelInput) error { return errors.New("telegram admin_user_ids required") } if str(cfg["group_chat_id"]) == "" && str(cfg["channel_chat_id"]) == "" && str(cfg["command_chat_id"]) == "" { - return errors.New("telegram group_chat_id or channel_chat_id required") + return errors.New("telegram group_chat_id or channel_chat_id required: 请填写绑定群组 ID 或绑定频道 ID;如果通知 Chat ID 是群组/频道负数 ID,也可以直接填在 Chat ID") } } return nil } +func normalizeChannelInput(in *ChannelInput) { + if in == nil || in.Type != "telegram" { + return + } + if in.Config == nil { + in.Config = map[string]any{} + } + chatID := str(in.Config["chat_id"]) + if chatID == "" || !strings.HasPrefix(chatID, "-") { + return + } + if str(in.Config["group_chat_id"]) == "" && str(in.Config["channel_chat_id"]) == "" && str(in.Config["command_chat_id"]) == "" { + in.Config["group_chat_id"] = chatID + } +} + // str safely extracts a string from an interface{} loaded from JSON. func str(v any) string { if v == nil { diff --git a/internal/service/notify_telegram.go b/internal/service/notify_telegram.go index f1f75ee..17b8dfd 100644 --- a/internal/service/notify_telegram.go +++ b/internal/service/notify_telegram.go @@ -2,12 +2,8 @@ package service import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "net/http" "strings" "time" ) @@ -35,30 +31,7 @@ func (p *TelegramProvider) Send(ctx context.Context, cfg map[string]string, even "text": text, "parse_mode": parseMode, } - body, err := json.Marshal(payload) - if err != nil { - return err - } - - apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - respBody, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 400 { - return fmt.Errorf("telegram api error %d: %s", resp.StatusCode, string(respBody)) - } - return nil + return telegramPostJSON(ctx, cfg, "sendMessage", payload, 15*time.Second) } // ValidateConfig 验证 Telegram 配置。 diff --git a/internal/service/telegram_api.go b/internal/service/telegram_api.go new file mode 100644 index 0000000..f7fe595 --- /dev/null +++ b/internal/service/telegram_api.go @@ -0,0 +1,126 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" +) + +const defaultTelegramAPIBaseURL = "https://api.telegram.org" + +var telegramTokenPattern = regexp.MustCompile(`bot[0-9]+:[^/\s"'?]+`) + +func telegramAPIBaseURL(cfg map[string]string) string { + base := strings.TrimSpace(cfg["api_base_url"]) + if base == "" { + base = strings.TrimSpace(os.Getenv("MEDIASTATION_TELEGRAM_API_BASE_URL")) + } + if base == "" { + base = defaultTelegramAPIBaseURL + } + return strings.TrimRight(base, "/") +} + +func telegramMethodURL(cfg map[string]string, botToken, method string) (string, error) { + botToken = strings.TrimSpace(botToken) + method = strings.TrimSpace(method) + if botToken == "" { + return "", errors.New("telegram bot_token required") + } + if method == "" { + return "", errors.New("telegram method required") + } + base := telegramAPIBaseURL(cfg) + if _, err := url.ParseRequestURI(base); err != nil { + return "", fmt.Errorf("telegram api_base_url invalid") + } + return fmt.Sprintf("%s/bot%s/%s", base, botToken, method), nil +} + +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")) + } + if proxyRaw != "" { + if proxyURL, err := normalizeProxyURL(proxyRaw, "http"); err == nil { + transport.Proxy = http.ProxyURL(proxyURL) + } + } + return &http.Client{Timeout: timeout, Transport: transport} +} + +func telegramPostForm(ctx context.Context, cfg map[string]string, method string, form url.Values, timeout time.Duration) error { + apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) + 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) +} + +func telegramPostJSON(ctx context.Context, cfg map[string]string, method string, payload any, timeout time.Duration) error { + apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) + if err != nil { + return err + } + body, err := json.Marshal(payload) + 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) +} + +func telegramDo(client *http.Client, req *http.Request) error { + resp, err := client.Do(req) + if err != nil { + return sanitizeTelegramError(err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(body))) + } + return nil +} + +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) + } + return out +} + +func sanitizeTelegramError(err error) error { + if err == nil { + return nil + } + msg := sanitizeTelegramText(err.Error()) + if strings.Contains(msg, "Client.Timeout exceeded") || strings.Contains(msg, "context deadline exceeded") { + return errors.New("telegram request timeout: 请检查 NAS/Docker 到 Telegram API 的代理、反代或网络连通性") + } + return errors.New(msg) +} + +func sanitizeTelegramText(text string) string { + return telegramTokenPattern.ReplaceAllString(text, "bot") +} diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go new file mode 100644 index 0000000..3d5a696 --- /dev/null +++ b/internal/service/telegram_api_test.go @@ -0,0 +1,34 @@ +package service + +import ( + "errors" + "strings" + "testing" +) + +func TestTelegramMethodURLUsesCustomAPIBase(t *testing.T) { + got, err := telegramMethodURL(map[string]string{ + "api_base_url": "https://tg.example.com/", + }, "123456:ABC-def", "sendMessage") + if err != nil { + t.Fatalf("telegramMethodURL returned error: %v", err) + } + want := "https://tg.example.com/bot123456:ABC-def/sendMessage" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestSanitizeTelegramErrorRedactsBotToken(t *testing.T) { + err := sanitizeTelegramError(errors.New(`Post "https://api.telegram.org/bot123456:SECRET/sendMessage": context deadline exceeded`)) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if strings.Contains(msg, "SECRET") || strings.Contains(msg, "123456:") { + t.Fatalf("telegram token leaked in error: %s", msg) + } + if !strings.Contains(msg, "timeout") { + t.Fatalf("expected timeout hint, got: %s", msg) + } +} diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 60389b2..ee6691c 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -5,7 +5,6 @@ package service import ( - "bytes" "context" "encoding/json" "fmt" @@ -478,7 +477,7 @@ func (s *TelegramBotService) StartPolling(ctx context.Context) { s.pollingCancel[botToken] = cancel s.pollingMu.Unlock() - go s.pollLoop(pollCtx, botToken) + go s.pollLoop(pollCtx, cfg) s.log.Info("started telegram polling", zap.String("channel", ch.Name)) } } @@ -495,10 +494,14 @@ func (s *TelegramBotService) StopPolling() { } // pollLoop 对单个 Bot Token 执行长轮询。 -func (s *TelegramBotService) pollLoop(ctx context.Context, botToken string) { +func (s *TelegramBotService) pollLoop(ctx context.Context, cfg map[string]string) { var offset int64 = 0 - pollURL := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates", botToken) - client := &http.Client{Timeout: 45 * time.Second} + pollURL, err := telegramMethodURL(cfg, cfg["bot_token"], "getUpdates") + if err != nil { + s.log.Warn("telegram polling config invalid", zap.Error(err)) + return + } + client := telegramHTTPClient(45*time.Second, cfg) for { select { @@ -511,7 +514,7 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, botToken string) { "offset": offset, "timeout": 30, }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, pollURL, bytes.NewReader(reqBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, pollURL, strings.NewReader(string(reqBody))) if err != nil { time.Sleep(5 * time.Second) continue @@ -520,6 +523,7 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, botToken string) { resp, err := client.Do(req) if err != nil { + s.log.Debug("telegram polling failed", zap.Error(sanitizeTelegramError(err))) time.Sleep(5 * time.Second) continue } @@ -554,18 +558,15 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, botToken string) { // reply 通过 Telegram Bot API 发送回复消息。 func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyChannel, chatID int, reply telegramCommandReply) error { - botToken := "" + cfg := map[string]string{} if channel != nil { configStr := channel.Config if s.crypto != nil && configStr != "" { configStr = s.crypto.Decrypt(configStr) } - var cfg map[string]string - if err := json.Unmarshal([]byte(configStr), &cfg); err == nil { - botToken = cfg["bot_token"] - } + _ = json.Unmarshal([]byte(configStr), &cfg) } - if botToken == "" { + if strings.TrimSpace(cfg["bot_token"]) == "" { return fmt.Errorf("bot_token not configured") } @@ -588,27 +589,7 @@ func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyCha } payload["reply_markup"] = map[string]interface{}{"inline_keyboard": keyboard} } - body, _ := json.Marshal(payload) - - apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("telegram api error %d: %s", resp.StatusCode, string(respBody)) - } - return nil + return telegramPostJSON(ctx, cfg, "sendMessage", payload, 15*time.Second) } // findChannelByChatID 根据 chat_id 查找已配置的通知渠道。 @@ -756,24 +737,27 @@ func (s *TelegramBotService) telegramUserCanBind(ctx context.Context, channel *m } func (s *TelegramBotService) telegramUserIsChatMember(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) bool { - token := strings.TrimSpace(s.telegramChannelConfig(channel)["bot_token"]) - if token == "" || chatID == "" || telegramUserID == 0 { + cfg := s.telegramChannelConfig(channel) + if strings.TrimSpace(cfg["bot_token"]) == "" || chatID == "" || telegramUserID == 0 { return false } - payload, _ := json.Marshal(map[string]interface{}{ + payload := map[string]interface{}{ "chat_id": chatID, "user_id": telegramUserID, - }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - fmt.Sprintf("https://api.telegram.org/bot%s/getChatMember", token), - bytes.NewReader(payload)) + } + 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 := (&http.Client{Timeout: 8 * time.Second}).Do(req) + 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(err)) + 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() @@ -884,33 +868,40 @@ func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookUR "url": webhookURL, "allowed_updates": []string{"message"}, }) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, - fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", botToken), - bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) + 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", string(body)) + return fmt.Errorf("setWebhook failed: %s", sanitizeTelegramText(string(body))) } return nil } // GetWebhookInfo 获取 Webhook 配置信息。 func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string) (map[string]interface{}, error) { - resp, err := (&http.Client{Timeout: 10 * time.Second}).Get( - fmt.Sprintf("https://api.telegram.org/bot%s/getWebhookInfo", botToken), - ) + cfg := map[string]string{"bot_token": botToken} + apiURL, err := telegramMethodURL(cfg, botToken, "getWebhookInfo") if 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{} diff --git a/web/src/pages/NotifyChannelsPage.tsx b/web/src/pages/NotifyChannelsPage.tsx index 7c97e2b..564cf06 100644 --- a/web/src/pages/NotifyChannelsPage.tsx +++ b/web/src/pages/NotifyChannelsPage.tsx @@ -183,7 +183,7 @@ function channelSummary(ch: NotifyChannel): string { const cfg = ch.config ?? {} switch (ch.type) { case 'telegram': - return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → 通知 ${cfg.chat_id ?? '-'} · 管理员 ${cfg.admin_user_ids ?? '-'} · 群组 ${cfg.group_chat_id ?? '-'} · 频道 ${cfg.channel_chat_id ?? '-'}` + return `Bot ${cfg.bot_token ? '已配置' : '未配置'} → 通知 ${cfg.chat_id ?? '-'} · 管理员 ${cfg.admin_user_ids ?? '-'} · 群组 ${cfg.group_chat_id ?? '-'} · 频道 ${cfg.channel_chat_id ?? '-'}` case 'wechat': return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…` case 'bark': @@ -200,7 +200,15 @@ function channelSummary(ch: NotifyChannel): string { // ─── Form Modal ───────────────────────────────────────────────────────────── const EMPTY_CONFIG: Record> = { - telegram: { bot_token: '', chat_id: '', admin_user_ids: '', group_chat_id: '', channel_chat_id: '' }, + telegram: { + bot_token: '', + chat_id: '', + admin_user_ids: '', + group_chat_id: '', + channel_chat_id: '', + api_base_url: '', + proxy_url: '', + }, wechat: { sendkey: '' }, bark: { device_key: '', server: '' }, webhook: { url: '', method: 'POST', headers: '', body_template: '' }, @@ -221,7 +229,7 @@ function ChannelFormModal({ editing?.type ?? 'telegram', ) const [config, setConfig] = useState>( - editing?.config ?? EMPTY_CONFIG.telegram, + { ...EMPTY_CONFIG[editing?.type ?? 'telegram'], ...(editing?.config ?? {}) }, ) const [enabled, setEnabled] = useState(editing?.enabled ?? true) const [saving, setSaving] = useState(false) @@ -233,12 +241,38 @@ function ChannelFormModal({ const onSubmit = async (e: FormEvent) => { e.preventDefault() + if (type === 'telegram') { + if (!String(config.bot_token ?? '').trim()) { + toast.error('请填写 Telegram Bot Token') + return + } + if (!String(config.chat_id ?? '').trim()) { + toast.error('请填写通知 Chat ID') + return + } + if (!String(config.admin_user_ids ?? '').trim()) { + toast.error('请填写管理员 Telegram ID') + return + } + const chatID = String(config.chat_id ?? '').trim() + const groupChatID = String(config.group_chat_id ?? '').trim() + const channelChatID = String(config.channel_chat_id ?? '').trim() + if (!groupChatID && !channelChatID && !chatID.startsWith('-')) { + toast.error('请至少填写绑定群组 ID 或绑定频道 ID;或把群组/频道负数 ID 填到 Chat ID') + return + } + if (!groupChatID && !channelChatID && chatID.startsWith('-')) { + config.group_chat_id = chatID + } + } setSaving(true) try { const input: NotifyChannelInput = { name: name.trim(), type: type, - config, + config: Object.fromEntries( + Object.entries(config).map(([key, value]) => [key, String(value ?? '').trim()]), + ), enabled, } if (editing) { @@ -336,8 +370,24 @@ function ChannelFormModal({ onChange={(e) => updateConfig('channel_chat_id', e.target.value)} /> + + updateConfig('api_base_url', e.target.value)} + /> + + + updateConfig('proxy_url', e.target.value)} + /> +
- 必须至少填写群组 ID 或频道 ID。只有配置群组/频道中的成员可以唤醒 Bot、使用 /start 用户名 密码 绑定账号和隐藏成人目录;/status、/search、/downloads、/stats 仅管理员 Telegram ID 或已绑定的本地管理员可用。 + 必须至少填写群组 ID 或频道 ID。只有配置群组/频道中的成员可以唤醒 Bot、使用 /start 用户名 密码 绑定账号和隐藏成人目录;/status、/search、/downloads、/stats 仅管理员 Telegram ID 或已绑定的本地管理员可用。若测试通知超时,可填写反代 API 地址或代理地址。
)}