Files
l-ui/internal/web/service/probenotify/channels.go
T

325 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
})
}