fix: improve telegram channel connectivity

This commit is contained in:
ShukeBta
2026-05-30 02:07:09 +08:00
parent be6b8bf27f
commit 7e01c42857
10 changed files with 335 additions and 131 deletions
+15 -13
View File
@@ -238,7 +238,7 @@ mkdir -p data cache media downloads
```bash ```bash
cat > .env <<'EOF' cat > .env <<'EOF'
# 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d # 固定版本;需要升级时改成新的 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 MEDIASTATION_HTTP_PORT=18080
# 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。 # 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。
@@ -307,7 +307,7 @@ vim docker-compose.yml
# #
# 镜像版本: # 镜像版本:
# 默认拉取 latest;如需固定版本,创建 .env 并写入: # 默认拉取 latest;如需固定版本,创建 .env 并写入:
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 # MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.23
# #
# 路径映射总览: # 路径映射总览:
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
@@ -516,7 +516,7 @@ docker compose up -d
```bash ```bash
cat > .env <<'EOF' cat > .env <<'EOF'
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.23
MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache MEDIASTATION_CACHE_DIR=./cache
@@ -779,26 +779,26 @@ cd MediaStationGo
| 平台 | 包名示例 | | 平台 | 包名示例 |
| --- | --- | | --- | --- |
| Linux x86_64 | `MediaStationGo-v0.0.22-linux-amd64.tar.gz` | | Linux x86_64 | `MediaStationGo-v0.0.23-linux-amd64.tar.gz` |
| Linux ARM64 | `MediaStationGo-v0.0.22-linux-arm64.tar.gz` | | Linux ARM64 | `MediaStationGo-v0.0.23-linux-arm64.tar.gz` |
| Windows x86_64 | `MediaStationGo-v0.0.22-windows-amd64.zip` | | Windows x86_64 | `MediaStationGo-v0.0.23-windows-amd64.zip` |
| macOS Intel | `MediaStationGo-v0.0.22-darwin-amd64.tar.gz` | | macOS Intel | `MediaStationGo-v0.0.23-darwin-amd64.tar.gz` |
| macOS Apple Silicon | `MediaStationGo-v0.0.22-darwin-arm64.tar.gz` | | macOS Apple Silicon | `MediaStationGo-v0.0.23-darwin-arm64.tar.gz` |
部署步骤: 部署步骤:
```bash ```bash
# Linux 示例 # Linux 示例
tar -xzf MediaStationGo-v0.0.22-linux-amd64.tar.gz tar -xzf MediaStationGo-v0.0.23-linux-amd64.tar.gz
cd MediaStationGo-v0.0.22-linux-amd64 cd MediaStationGo-v0.0.23-linux-amd64
MEDIASTATION_APP_PORT=18080 ./mediastation-go MEDIASTATION_APP_PORT=18080 ./mediastation-go
``` ```
Windows: Windows:
```powershell ```powershell
Expand-Archive .\MediaStationGo-v0.0.22-windows-amd64.zip Expand-Archive .\MediaStationGo-v0.0.23-windows-amd64.zip
cd .\MediaStationGo-v0.0.22-windows-amd64 cd .\MediaStationGo-v0.0.23-windows-amd64
$env:MEDIASTATION_APP_PORT = "18080" $env:MEDIASTATION_APP_PORT = "18080"
.\mediastation-go.exe .\mediastation-go.exe
``` ```
@@ -905,13 +905,15 @@ MediaStationGo/
| `MEDIASTATION_CACHE_CACHE_DIR` | `./cache` | 图片/转码缓存目录 | | `MEDIASTATION_CACHE_CACHE_DIR` | `./cache` | 图片/转码缓存目录 |
| `MEDIASTATION_SECRETS_JWT_SECRET` | 自动生成 | JWT 和敏感配置加密种子 | | `MEDIASTATION_SECRETS_JWT_SECRET` | 自动生成 | JWT 和敏感配置加密种子 |
| `MEDIASTATION_APP_CORS_ORIGINS` | 空 | 额外允许的跨域来源 | | `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 等。 - API Key:TMDb、Bangumi、TheTVDB、Fanart、OpenAI Compatible 等。
- 站点:M-Team、NexusPHP、Unit3D、自定义 RSS 等。 - 站点:M-Team、NexusPHP、Unit3D、自定义 RSS 等。
- 下载器:qBittorrent、Transmission、Aria2。 - 下载器:qBittorrent、Transmission、Aria2。
- 通知渠道:Telegram、Bark、Webhook、Email 等。 - 通知渠道:Telegram、Bark、Webhook、Email 等。Telegram 渠道支持单独配置 API 反代与代理地址,测试通知失败时错误信息会自动隐藏 Bot Token。
- 播放配置、权限配置、调度任务、存储配置。 - 播放配置、权限配置、调度任务、存储配置。
--- ---
+14 -12
View File
@@ -235,7 +235,7 @@ mkdir -p data cache media downloads
```bash ```bash
cat > .env <<'EOF' cat > .env <<'EOF'
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.23
MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache MEDIASTATION_CACHE_DIR=./cache
@@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende
```bash ```bash
cat > .env <<'EOF' cat > .env <<'EOF'
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.23
MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache MEDIASTATION_CACHE_DIR=./cache
@@ -593,25 +593,25 @@ Each release provides multi-platform archives:
| Platform | Package example | | Platform | Package example |
| --- | --- | | --- | --- |
| Linux x86_64 | `MediaStationGo-v0.0.22-linux-amd64.tar.gz` | | Linux x86_64 | `MediaStationGo-v0.0.23-linux-amd64.tar.gz` |
| Linux ARM64 | `MediaStationGo-v0.0.22-linux-arm64.tar.gz` | | Linux ARM64 | `MediaStationGo-v0.0.23-linux-arm64.tar.gz` |
| Windows x86_64 | `MediaStationGo-v0.0.22-windows-amd64.zip` | | Windows x86_64 | `MediaStationGo-v0.0.23-windows-amd64.zip` |
| macOS Intel | `MediaStationGo-v0.0.22-darwin-amd64.tar.gz` | | macOS Intel | `MediaStationGo-v0.0.23-darwin-amd64.tar.gz` |
| macOS Apple Silicon | `MediaStationGo-v0.0.22-darwin-arm64.tar.gz` | | macOS Apple Silicon | `MediaStationGo-v0.0.23-darwin-arm64.tar.gz` |
Linux example: Linux example:
```bash ```bash
tar -xzf MediaStationGo-v0.0.22-linux-amd64.tar.gz tar -xzf MediaStationGo-v0.0.23-linux-amd64.tar.gz
cd MediaStationGo-v0.0.22-linux-amd64 cd MediaStationGo-v0.0.23-linux-amd64
MEDIASTATION_APP_PORT=18080 ./mediastation-go MEDIASTATION_APP_PORT=18080 ./mediastation-go
``` ```
Windows example: Windows example:
```powershell ```powershell
Expand-Archive .\MediaStationGo-v0.0.22-windows-amd64.zip Expand-Archive .\MediaStationGo-v0.0.23-windows-amd64.zip
cd .\MediaStationGo-v0.0.22-windows-amd64 cd .\MediaStationGo-v0.0.23-windows-amd64
$env:MEDIASTATION_APP_PORT = "18080" $env:MEDIASTATION_APP_PORT = "18080"
.\mediastation-go.exe .\mediastation-go.exe
``` ```
@@ -718,13 +718,15 @@ Common variables:
| `MEDIASTATION_CACHE_CACHE_DIR` | `./cache` | Image/transcode cache | | `MEDIASTATION_CACHE_CACHE_DIR` | `./cache` | Image/transcode cache |
| `MEDIASTATION_SECRETS_JWT_SECRET` | Auto-generated | JWT and encrypted settings seed | | `MEDIASTATION_SECRETS_JWT_SECRET` | Auto-generated | JWT and encrypted settings seed |
| `MEDIASTATION_APP_CORS_ORIGINS` | empty | Extra CORS origins | | `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: Runtime settings from the admin UI:
- API keys: TMDb, Bangumi, TheTVDB, Fanart, OpenAI Compatible. - API keys: TMDb, Bangumi, TheTVDB, Fanart, OpenAI Compatible.
- Sites: M-Team, NexusPHP, Unit3D, custom RSS. - Sites: M-Team, NexusPHP, Unit3D, custom RSS.
- Download clients: qBittorrent, Transmission, Aria2. - 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. - Playback profiles, permissions, scheduler tasks, storage settings.
--- ---
+8 -1
View File
@@ -17,7 +17,7 @@
# #
# 镜像版本: # 镜像版本:
# 默认拉取 latest;如需固定版本,创建 .env 并写入: # 默认拉取 latest;如需固定版本,创建 .env 并写入:
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 # MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.23
# #
# 路径映射总览: # 路径映射总览:
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
@@ -141,6 +141,13 @@ services:
# 跨域来源。通常无需设置;反向代理或三端客户端异常时再按需填写。 # 跨域来源。通常无需设置;反向代理或三端客户端异常时再按需填写。
MEDIASTATION_APP_CORS_ORIGINS: ${MEDIASTATION_APP_CORS_ORIGINS:-} 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 命令查看。 # 宿主机文件权限映射。Linux/NAS 常用 1000:1000,可用 id 命令查看。
PUID: ${PUID:-1000} PUID: ${PUID:-1000}
PGID: ${PGID:-1000} PGID: ${PGID:-1000}
+14 -11
View File
@@ -3,10 +3,10 @@
// NotifierService dispatches structured messages to one or more channels // NotifierService dispatches structured messages to one or more channels
// configured in the system settings table: // configured in the system settings table:
// //
// notify.telegram.bot_token + notify.telegram.chat_id // notify.telegram.bot_token + notify.telegram.chat_id
// notify.bark.server + notify.bark.key // notify.bark.server + notify.bark.key
// notify.wechat.sendkey // notify.wechat.sendkey
// notify.webhook.url + notify.webhook.method // notify.webhook.url + notify.webhook.method
// //
// Notifications are triggered by the subscription poller, the download // Notifications are triggered by the subscription poller, the download
// poller, the scan / scrape completions, and any future event worth // 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{ return &NotifierService{
log: log, log: log,
repo: repo, 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 return
} }
text := fmt.Sprintf("<b>%s</b>\n\n%s", escapeHTML(title), escapeHTML(body)) text := fmt.Sprintf("<b>%s</b>\n\n%s", escapeHTML(title), escapeHTML(body))
u := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
form := url.Values{} form := url.Values{}
form.Set("chat_id", chatID) form.Set("chat_id", chatID)
form.Set("text", text) form.Set("text", text)
form.Set("parse_mode", "HTML") form.Set("parse_mode", "HTML")
resp, err := n.client.PostForm(u, form) cfg := map[string]string{"bot_token": token}
if err != nil { if apiBase := n.get(ctx, "notify.telegram.api_base_url"); apiBase != "" {
n.log.Debug("telegram notify failed", zap.Error(err)) cfg["api_base_url"] = apiBase
return }
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) { func (n *NotifierService) sendBark(ctx context.Context, title, body string) {
+24 -8
View File
@@ -34,7 +34,7 @@ func NewNotifyChannelService(log *zap.Logger, repo *repository.Container) *Notif
return &NotifyChannelService{ return &NotifyChannelService{
log: log, log: log,
repo: repo, 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. // Create persists a new channel.
func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*channelView, error) { func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*channelView, error) {
normalizeChannelInput(&in)
if err := validateChannel(in); err != nil { if err := validateChannel(in); err != nil {
return nil, err 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. // Update applies a partial patch to an existing channel.
func (s *NotifyChannelService) Update(ctx context.Context, id string, in ChannelInput) (*channelView, error) { func (s *NotifyChannelService) Update(ctx context.Context, id string, in ChannelInput) (*channelView, error) {
normalizeChannelInput(&in)
if err := validateChannel(in); err != nil { if err := validateChannel(in); err != nil {
return nil, err return nil, err
} }
@@ -218,20 +220,18 @@ func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyCh
switch n.Type { switch n.Type {
case "telegram": case "telegram":
token := str(cfg["bot_token"]) telegramCfg := telegramStringConfigFromAny(cfg)
chat := str(cfg["chat_id"]) token := telegramCfg["bot_token"]
chat := telegramCfg["chat_id"]
if token == "" || chat == "" { if token == "" || chat == "" {
return errors.New("telegram missing bot_token / chat_id") return errors.New("telegram missing bot_token / chat_id")
} }
text := fmt.Sprintf("<b>%s</b>\n\n%s", escapeHTML(title), escapeHTML(body)) text := fmt.Sprintf("<b>%s</b>\n\n%s", escapeHTML(title), escapeHTML(body))
u := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
form := url.Values{} form := url.Values{}
form.Set("chat_id", chat) form.Set("chat_id", chat)
form.Set("text", text) form.Set("text", text)
form.Set("parse_mode", "HTML") form.Set("parse_mode", "HTML")
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) return telegramPostForm(ctx, telegramCfg, "sendMessage", form, 15*time.Second)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return s.do(req)
case "bark": case "bark":
key := str(cfg["device_key"]) key := str(cfg["device_key"])
@@ -332,12 +332,28 @@ func validateChannel(in ChannelInput) error {
return errors.New("telegram admin_user_ids required") return errors.New("telegram admin_user_ids required")
} }
if str(cfg["group_chat_id"]) == "" && str(cfg["channel_chat_id"]) == "" && str(cfg["command_chat_id"]) == "" { 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 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. // str safely extracts a string from an interface{} loaded from JSON.
func str(v any) string { func str(v any) string {
if v == nil { if v == nil {
+1 -28
View File
@@ -2,12 +2,8 @@
package service package service
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"strings" "strings"
"time" "time"
) )
@@ -35,30 +31,7 @@ func (p *TelegramProvider) Send(ctx context.Context, cfg map[string]string, even
"text": text, "text": text,
"parse_mode": parseMode, "parse_mode": parseMode,
} }
body, err := json.Marshal(payload) return telegramPostJSON(ctx, cfg, "sendMessage", payload, 15*time.Second)
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
} }
// ValidateConfig 验证 Telegram 配置。 // ValidateConfig 验证 Telegram 配置。
+126
View File
@@ -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<redacted>")
}
+34
View File
@@ -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)
}
}
+44 -53
View File
@@ -5,7 +5,6 @@
package service package service
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -478,7 +477,7 @@ func (s *TelegramBotService) StartPolling(ctx context.Context) {
s.pollingCancel[botToken] = cancel s.pollingCancel[botToken] = cancel
s.pollingMu.Unlock() s.pollingMu.Unlock()
go s.pollLoop(pollCtx, botToken) go s.pollLoop(pollCtx, cfg)
s.log.Info("started telegram polling", zap.String("channel", ch.Name)) s.log.Info("started telegram polling", zap.String("channel", ch.Name))
} }
} }
@@ -495,10 +494,14 @@ func (s *TelegramBotService) StopPolling() {
} }
// pollLoop 对单个 Bot Token 执行长轮询。 // 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 var offset int64 = 0
pollURL := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates", botToken) pollURL, err := telegramMethodURL(cfg, cfg["bot_token"], "getUpdates")
client := &http.Client{Timeout: 45 * time.Second} if err != nil {
s.log.Warn("telegram polling config invalid", zap.Error(err))
return
}
client := telegramHTTPClient(45*time.Second, cfg)
for { for {
select { select {
@@ -511,7 +514,7 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, botToken string) {
"offset": offset, "offset": offset,
"timeout": 30, "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 { if err != nil {
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
continue continue
@@ -520,6 +523,7 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, botToken string) {
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
s.log.Debug("telegram polling failed", zap.Error(sanitizeTelegramError(err)))
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
continue continue
} }
@@ -554,18 +558,15 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, botToken string) {
// reply 通过 Telegram Bot API 发送回复消息。 // reply 通过 Telegram Bot API 发送回复消息。
func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyChannel, chatID int, reply telegramCommandReply) error { func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyChannel, chatID int, reply telegramCommandReply) error {
botToken := "" cfg := map[string]string{}
if channel != nil { if channel != nil {
configStr := channel.Config configStr := channel.Config
if s.crypto != nil && configStr != "" { if s.crypto != nil && configStr != "" {
configStr = s.crypto.Decrypt(configStr) configStr = s.crypto.Decrypt(configStr)
} }
var cfg map[string]string _ = json.Unmarshal([]byte(configStr), &cfg)
if err := json.Unmarshal([]byte(configStr), &cfg); err == nil {
botToken = cfg["bot_token"]
}
} }
if botToken == "" { if strings.TrimSpace(cfg["bot_token"]) == "" {
return fmt.Errorf("bot_token not configured") 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} payload["reply_markup"] = map[string]interface{}{"inline_keyboard": keyboard}
} }
body, _ := json.Marshal(payload) return telegramPostJSON(ctx, cfg, "sendMessage", payload, 15*time.Second)
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
} }
// findChannelByChatID 根据 chat_id 查找已配置的通知渠道。 // 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 { func (s *TelegramBotService) telegramUserIsChatMember(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) bool {
token := strings.TrimSpace(s.telegramChannelConfig(channel)["bot_token"]) cfg := s.telegramChannelConfig(channel)
if token == "" || chatID == "" || telegramUserID == 0 { if strings.TrimSpace(cfg["bot_token"]) == "" || chatID == "" || telegramUserID == 0 {
return false return false
} }
payload, _ := json.Marshal(map[string]interface{}{ payload := map[string]interface{}{
"chat_id": chatID, "chat_id": chatID,
"user_id": telegramUserID, "user_id": telegramUserID,
}) }
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], "getChatMember")
fmt.Sprintf("https://api.telegram.org/bot%s/getChatMember", token), if err != nil {
bytes.NewReader(payload)) return false
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(body)))
if err != nil { if err != nil {
return false return false
} }
req.Header.Set("Content-Type", "application/json") 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 { 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 return false
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -884,33 +868,40 @@ func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookUR
"url": webhookURL, "url": webhookURL,
"allowed_updates": []string{"message"}, "allowed_updates": []string{"message"},
}) })
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, cfg := map[string]string{"bot_token": botToken}
fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", botToken), apiURL, err := telegramMethodURL(cfg, botToken, "setWebhook")
bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil { if err != nil {
return err 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() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("setWebhook failed: %s", string(body)) return fmt.Errorf("setWebhook failed: %s", sanitizeTelegramText(string(body)))
} }
return nil return nil
} }
// GetWebhookInfo 获取 Webhook 配置信息。 // GetWebhookInfo 获取 Webhook 配置信息。
func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string) (map[string]interface{}, error) { func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string) (map[string]interface{}, error) {
resp, err := (&http.Client{Timeout: 10 * time.Second}).Get( cfg := map[string]string{"bot_token": botToken}
fmt.Sprintf("https://api.telegram.org/bot%s/getWebhookInfo", botToken), apiURL, err := telegramMethodURL(cfg, botToken, "getWebhookInfo")
)
if err != nil { if err != nil {
return nil, err 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() defer resp.Body.Close()
var result map[string]interface{} var result map[string]interface{}
+55 -5
View File
@@ -183,7 +183,7 @@ function channelSummary(ch: NotifyChannel): string {
const cfg = ch.config ?? {} const cfg = ch.config ?? {}
switch (ch.type) { switch (ch.type) {
case 'telegram': 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': case 'wechat':
return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…` return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…`
case 'bark': case 'bark':
@@ -200,7 +200,15 @@ function channelSummary(ch: NotifyChannel): string {
// ─── Form Modal ───────────────────────────────────────────────────────────── // ─── Form Modal ─────────────────────────────────────────────────────────────
const EMPTY_CONFIG: Record<NotifyChannel['type'], Record<string, string>> = { const EMPTY_CONFIG: Record<NotifyChannel['type'], Record<string, string>> = {
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: '' }, wechat: { sendkey: '' },
bark: { device_key: '', server: '' }, bark: { device_key: '', server: '' },
webhook: { url: '', method: 'POST', headers: '', body_template: '' }, webhook: { url: '', method: 'POST', headers: '', body_template: '' },
@@ -221,7 +229,7 @@ function ChannelFormModal({
editing?.type ?? 'telegram', editing?.type ?? 'telegram',
) )
const [config, setConfig] = useState<Record<string, string>>( const [config, setConfig] = useState<Record<string, string>>(
editing?.config ?? EMPTY_CONFIG.telegram, { ...EMPTY_CONFIG[editing?.type ?? 'telegram'], ...(editing?.config ?? {}) },
) )
const [enabled, setEnabled] = useState(editing?.enabled ?? true) const [enabled, setEnabled] = useState(editing?.enabled ?? true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -233,12 +241,38 @@ function ChannelFormModal({
const onSubmit = async (e: FormEvent) => { const onSubmit = async (e: FormEvent) => {
e.preventDefault() 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) setSaving(true)
try { try {
const input: NotifyChannelInput = { const input: NotifyChannelInput = {
name: name.trim(), name: name.trim(),
type: type, type: type,
config, config: Object.fromEntries(
Object.entries(config).map(([key, value]) => [key, String(value ?? '').trim()]),
),
enabled, enabled,
} }
if (editing) { if (editing) {
@@ -336,8 +370,24 @@ function ChannelFormModal({
onChange={(e) => updateConfig('channel_chat_id', e.target.value)} onChange={(e) => updateConfig('channel_chat_id', e.target.value)}
/> />
</Field> </Field>
<Field label="Telegram API 地址 (可选)">
<input
className="input-base"
placeholder="默认 https://api.telegram.org;反代可填 https://tg.example.com"
value={config.api_base_url ?? ''}
onChange={(e) => updateConfig('api_base_url', e.target.value)}
/>
</Field>
<Field label="Telegram 代理 (可选)">
<input
className="input-base"
placeholder="如 http://172.17.0.1:7890 或 socks5://172.17.0.1:1080"
value={config.proxy_url ?? ''}
onChange={(e) => updateConfig('proxy_url', e.target.value)}
/>
</Field>
<div className="rounded-2xl border border-primary-400/15 bg-primary-400/5 px-4 py-3 text-xs leading-6 text-ink-50"> <div className="rounded-2xl border border-primary-400/15 bg-primary-400/5 px-4 py-3 text-xs leading-6 text-ink-50">
必须至少填写群组 ID 或频道 ID。只有配置群组/频道中的成员可以唤醒 Bot、使用 <code>/start 用户名 密码</code> 绑定账号和隐藏成人目录;<code>/status</code>、<code>/search</code>、<code>/downloads</code>、<code>/stats</code> 仅管理员 Telegram ID 或已绑定的本地管理员可用。 必须至少填写群组 ID 或频道 ID。只有配置群组/频道中的成员可以唤醒 Bot、使用 <code>/start 用户名 密码</code> 绑定账号和隐藏成人目录;<code>/status</code>、<code>/search</code>、<code>/downloads</code>、<code>/stats</code> 仅管理员 Telegram ID 或已绑定的本地管理员可用。若测试通知超时,可填写反代 API 地址或代理地址。
</div> </div>
</> </>
)} )}