mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
fix: improve telegram channel connectivity
This commit is contained in:
@@ -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。
|
||||
- 播放配置、权限配置、调度任务、存储配置。
|
||||
|
||||
---
|
||||
|
||||
+14
-12
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
+8
-1
@@ -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}
|
||||
|
||||
@@ -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("<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.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) {
|
||||
|
||||
@@ -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("<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.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 {
|
||||
|
||||
@@ -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 配置。
|
||||
|
||||
@@ -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>")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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<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: '' },
|
||||
bark: { device_key: '', server: '' },
|
||||
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
|
||||
@@ -221,7 +229,7 @@ function ChannelFormModal({
|
||||
editing?.type ?? 'telegram',
|
||||
)
|
||||
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 [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)}
|
||||
/>
|
||||
</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">
|
||||
必须至少填写群组 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user