325 lines
9.4 KiB
Go
325 lines
9.4 KiB
Go
package probenotify
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||
)
|
||
|
||
// Delivery: a queue of 64 drained by one worker, each channel retried
|
||
// independently (3 attempts, 10s apart, retryable failures only).
|
||
// At most once: an alert that exhausts its attempts is dropped, and
|
||
// the state that produced it (the offline mark, the traffic step,
|
||
// the digest date) already records it as sent, so it is not raised
|
||
// again (hub deliver()).
|
||
const (
|
||
queueSize = 64
|
||
attempts = 3
|
||
retryPause = 10 * time.Second
|
||
postTimeout = 15 * time.Second
|
||
)
|
||
|
||
var (
|
||
inbox = make(chan Note, queueSize)
|
||
workerOnce sync.Once
|
||
)
|
||
|
||
// Send queues an alert without waiting, so that sign-in and the sweep
|
||
// never stall behind a slow channel. Beyond the queue the alert is
|
||
// dropped with a warning rather than held in memory without bound.
|
||
func Send(note Note) {
|
||
note.Time = time.Now().Unix()
|
||
workerOnce.Do(func() { go deliver() })
|
||
select {
|
||
case inbox <- note:
|
||
default:
|
||
logger.Warningf("probenotify: queue full, dropped %q", note.Title)
|
||
}
|
||
}
|
||
|
||
func deliver() {
|
||
for note := range inbox {
|
||
for _, ch := range channelsFn() {
|
||
for attempt := 1; attempt <= attempts; attempt++ {
|
||
f := ch.post(note)
|
||
if f == nil {
|
||
break
|
||
}
|
||
if !f.retry || attempt == attempts {
|
||
logger.Warningf("probenotify: %s alert %q not delivered: %s", ch.name(), note.Title, f.reason)
|
||
break
|
||
}
|
||
logger.Debugf("probenotify: %s alert %q, attempt %d: %s", ch.name(), note.Title, attempt, f.reason)
|
||
time.Sleep(retryPause)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// failure is why a delivery failed, and whether another attempt could
|
||
// change that. A refused credential or a malformed request fails
|
||
// identically every time, and retrying it would hold the queue.
|
||
// reason is for the log; shown is what the panel says, composed from
|
||
// the status rather than quoted from the far end's answer. Neither
|
||
// ever carries the request URL: Telegram's carries the bot token and
|
||
// a webhook's is often the credential itself.
|
||
type failure struct {
|
||
retry bool
|
||
reason string
|
||
shown string
|
||
}
|
||
|
||
// channel is one configured delivery target, read from the settings
|
||
// at send time so a panel edit applies to the next alert.
|
||
type channel interface {
|
||
name() string
|
||
post(note Note) *failure
|
||
}
|
||
|
||
var channelsFn = resolveChannels
|
||
|
||
func resolveChannels() []channel {
|
||
var out []channel
|
||
if tg, ok := buildTelegramChannel(); ok {
|
||
out = append(out, tg)
|
||
}
|
||
if v, ok := settingFn("notify_webhook_url"); ok && v != "" {
|
||
headers, _ := settingFn("notify_webhook_headers")
|
||
out = append(out, webhookChannel{
|
||
url: v,
|
||
headers: headers,
|
||
body: templateSetting("notify_webhook_body", defaultBody),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TelegramConfigured reports whether the panel bot settings amount
|
||
// to a usable Telegram channel (token + at least one numeric chat).
|
||
func TelegramConfigured() bool {
|
||
_, ok := buildTelegramChannel()
|
||
return ok
|
||
}
|
||
|
||
// WebhookConfigured reports whether a webhook URL is set.
|
||
func WebhookConfigured() bool {
|
||
v, ok := settingFn("notify_webhook_url")
|
||
return ok && v != ""
|
||
}
|
||
|
||
// buildTelegramChannel builds the Telegram channel from the PANEL bot's
|
||
// settings (tgBotToken / tgBotChatId / tgBotProxy / tgBotAPIServer):
|
||
// the notify system deliberately keeps no Telegram credentials of its
|
||
// own (ITEM7-SCOPE §5.6). The chat setting is the bot's admin list —
|
||
// comma-separated numeric ids; the alert goes to each.
|
||
func buildTelegramChannel() (telegramChannel, bool) {
|
||
token, _ := settingFn("tgBotToken")
|
||
rawChats, _ := settingFn("tgBotChatId")
|
||
var chats []int64
|
||
for _, part := range strings.Split(rawChats, ",") {
|
||
part = strings.TrimSpace(part)
|
||
if part == "" {
|
||
continue
|
||
}
|
||
if id, err := strconv.ParseInt(part, 10, 64); err == nil {
|
||
chats = append(chats, id)
|
||
}
|
||
}
|
||
if token == "" || len(chats) == 0 {
|
||
return telegramChannel{}, false
|
||
}
|
||
apiBase, _ := settingFn("tgBotAPIServer")
|
||
proxy, _ := settingFn("tgBotProxy")
|
||
return telegramChannel{
|
||
token: token,
|
||
chats: chats,
|
||
text: templateSetting("notify_telegram_text", defaultText),
|
||
apiBase: strings.TrimSuffix(apiBase, "/"),
|
||
proxy: proxy,
|
||
}, true
|
||
}
|
||
|
||
type telegramChannel struct {
|
||
token string
|
||
chats []int64
|
||
text string
|
||
apiBase string
|
||
proxy string
|
||
}
|
||
|
||
func (telegramChannel) name() string { return "Telegram" }
|
||
|
||
func (ch telegramChannel) post(note Note) *failure {
|
||
base := ch.apiBase
|
||
if base == "" {
|
||
base = "https://api.telegram.org"
|
||
}
|
||
// Plain text: under a parse mode Telegram rejects a message
|
||
// whose node name happens to contain markup.
|
||
text := render(ch.text, note, siteName(), false)
|
||
for _, chat := range ch.chats {
|
||
body, _ := json.Marshal(map[string]any{"chat_id": chat, "text": text})
|
||
req, err := http.NewRequest(http.MethodPost, base+"/bot"+ch.token+"/sendMessage", bytes.NewReader(body))
|
||
if err != nil {
|
||
return &failure{retry: false, reason: "请求构造失败", shown: "请求构造失败"}
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if f := doPost(httpClient(ch.proxy), req, true); f != nil {
|
||
return f
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type webhookChannel struct {
|
||
url string
|
||
headers string
|
||
body string
|
||
}
|
||
|
||
func (webhookChannel) name() string { return "Webhook" }
|
||
|
||
func (ch webhookChannel) post(note Note) *failure {
|
||
headers, perr := parseHeaders(ch.headers)
|
||
if perr != "" {
|
||
return &failure{retry: false, reason: perr, shown: perr}
|
||
}
|
||
req, err := http.NewRequest(http.MethodPost, ch.url,
|
||
strings.NewReader(render(ch.body, note, siteName(), true)))
|
||
if err != nil {
|
||
return &failure{retry: false, reason: "请求构造失败", shown: "请求构造失败"}
|
||
}
|
||
// One map, assigned not appended: a configured Content-Type takes
|
||
// the default's place instead of riding alongside it (hub).
|
||
req.Header.Set("Content-Type", "application/json")
|
||
for _, h := range headers {
|
||
req.Header.Set(h[0], h[1])
|
||
}
|
||
return doPost(httpClient(""), req, false)
|
||
}
|
||
|
||
// httpClient is the alerts' client: 15s timeout and no redirect
|
||
// following (a redirected POST loses its body or leaks credential
|
||
// headers to the next host — hub builds its client the same way).
|
||
// Telegram posts may ride the panel bot's configured proxy.
|
||
func httpClient(proxy string) *http.Client {
|
||
transport := &http.Transport{}
|
||
if proxy != "" {
|
||
if u, err := url.Parse(proxy); err == nil && u.Host != "" {
|
||
transport.Proxy = http.ProxyURL(u)
|
||
}
|
||
}
|
||
return &http.Client{
|
||
Timeout: postTimeout,
|
||
Transport: transport,
|
||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||
return http.ErrUseLastResponse
|
||
},
|
||
}
|
||
}
|
||
|
||
func doPost(client *http.Client, req *http.Request, telegram bool) *failure {
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
// The error itself is never quoted: it embeds the URL.
|
||
shown := "连不上对方服务器"
|
||
var netErr interface{ Timeout() bool }
|
||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||
shown = "请求超时"
|
||
}
|
||
return &failure{retry: true, reason: shown, shown: shown}
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||
return nil
|
||
}
|
||
// The first chunk carries the reason (Telegram's description,
|
||
// Discord's message) without reading an error page of arbitrary
|
||
// length.
|
||
head, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||
code := resp.StatusCode
|
||
var why string
|
||
switch {
|
||
case code >= 300 && code <= 399:
|
||
why = "地址发生了跳转,请填写跳转后的地址"
|
||
case telegram && (code == 401 || code == 404):
|
||
why = "Bot Token 不对"
|
||
case telegram && code == 400:
|
||
why = "Chat ID 不对,或者 bot 还没有加入这个会话"
|
||
case telegram && code == 403:
|
||
why = "bot 被这个会话移除或屏蔽了"
|
||
case code == 401 || code == 403:
|
||
why = "对方拒绝了鉴权,检查 URL 或请求头里的凭据"
|
||
case code == 404:
|
||
why = "地址不存在,检查 URL"
|
||
case code == 429:
|
||
why = "发送太频繁,被对方限流"
|
||
case code >= 500:
|
||
why = "对方服务器出错"
|
||
default:
|
||
why = "对方拒收了这条消息,检查请求体格式"
|
||
}
|
||
reason := strings.TrimSpace(string(head))
|
||
if len(reason) > 300 {
|
||
reason = reason[:300]
|
||
}
|
||
return &failure{
|
||
retry: code >= 500 || code == http.StatusTooManyRequests,
|
||
reason: fmt.Sprintf("HTTP %d: %s", code, reason),
|
||
shown: fmt.Sprintf("%s(HTTP %d)", why, code),
|
||
}
|
||
}
|
||
|
||
// Test sends one alert through every configured channel, past the
|
||
// queue and its retries, so each channel's own error reaches the
|
||
// panel (hub POST /api/notify/test). No channel configured is an
|
||
// error of its own: "还没有配置通知渠道".
|
||
func Test() (sent []string, err error) {
|
||
channels := channelsFn()
|
||
if len(channels) == 0 {
|
||
return nil, errors.New("还没有配置通知渠道")
|
||
}
|
||
note := Note{
|
||
Event: "test",
|
||
Title: "✅ 测试通知",
|
||
Message: "收到这条说明通知渠道可用",
|
||
Time: time.Now().Unix(),
|
||
}
|
||
var failed []string
|
||
for _, ch := range channels {
|
||
if f := ch.post(note); f != nil {
|
||
logger.Warningf("probenotify: %s test alert not delivered: %s", ch.name(), f.reason)
|
||
failed = append(failed, fmt.Sprintf("%s:%s", ch.name(), f.shown))
|
||
} else {
|
||
sent = append(sent, ch.name())
|
||
}
|
||
}
|
||
if len(failed) > 0 {
|
||
return sent, errors.New(strings.Join(failed, ";"))
|
||
}
|
||
return sent, nil
|
||
}
|
||
|
||
// SignedIn raises the sign-in alert (hub signed_in): on by default,
|
||
// the notify_login setting turns it off.
|
||
func SignedIn(how, ip string) {
|
||
if !loginEnabled() {
|
||
return
|
||
}
|
||
Send(Note{
|
||
Event: "login",
|
||
Title: "🔑 面板登录",
|
||
Message: fmt.Sprintf("%s · 来自 %s", how, ip),
|
||
})
|
||
}
|