// Package probenotify is the item 7 notification engine: the monitor // hub's notify.rs, re-homed on the panel's own stores (ITEM7-SCOPE // §3.5/§5.6/B6). Events: a node going offline and returning, a billing // period's traffic crossing the configured step and the full // allowance, expiry dates approaching (a daily digest) or rolled // forward for a node still online past them, and a sign-in to the // panel. // // Channels: Telegram through the panel's existing bot (the // tgBotToken/tgBotChatId settings — the hub's separate // notify_telegram_token/chat are deliberately NOT ported, one bot // configuration serves both) and a new webhook channel (JSON body // template, custom headers, no redirect following). Delivery is // queued and retried per channel; nothing here may stall or fail the // panel flow that raised the event — a dead channel degrades to log // lines only. // // Durable state (offline marks, traffic steps already announced, the // digest's last-sent date) lives in probe.db's probe_setting through // the probe package, so a master restart neither replays an offline // alert nor loses the recovery pairing. package probenotify import ( "encoding/json" "fmt" "strconv" "strings" "time" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) // Note is one alert. Node lists the node names it concerns, // comma-separated, and is empty for a sign-in. type Note struct { Event string Node string Title string Message string // Unix seconds, stamped when queued: delivery can trail the // event by minutes while a channel is retried. Time int64 } // Numeric settings as (key, min, max, default, the panel's name for // it) — the hub's NUMBERS table, unchanged. var numbersTable = []struct { key, label string min, max, def int64 }{ // Minutes a node may stay away before it is reported. Capped at // the flap grace (30 min): a longer grace would outwait a // flapping node too. {"notify_grace", "离线宽限期", 1, flapGrace / 60, 3}, // Percent of the allowance that raises the first traffic alert; // 0 disables traffic alerts. {"notify_traffic", "流量提醒", 0, 100, 80}, // Days ahead an expiry is listed; 0 disables both expiry // reminders and renewal notices. {"notify_expiry", "到期提醒", 0, 365, 7}, } const ( // How often connectivity and traffic are examined (the sweep // job's cadence); an offline alert trails the grace period by at // most this. sweepInterval = 30 * time.Second // A node that returned from an absence longer than the grace // period within flapWindow of going away again is flapping, and // that absence is reported only once it lasts flapGrace. flapWindow = 3600 // seconds flapGrace = 1800 // seconds // Nodes listed in one alert before the rest are only counted. listedMax = 20 // onlineWindow is how fresh an agent's last heartbeat must be // for the sweep to call it online — the B3 public view's rule // (LastSeen ≤150s against the 60s heartbeat). onlineWindow = 150 // seconds defaultBody = `{"event":"{{event}}","node":"{{node}}","title":"{{title}}","message":"{{message}}"}` defaultText = "{{title}}\n{{message}}" ) // setting reads one main-store settings value; ok=false when unset. // The notify keys live in the same KV as every other panel setting // (hub names kept), read fresh at each use so a panel edit applies // to the next alert. var settingFn = func(key string) (string, bool) { var row model.Setting if err := database.GetDB().Where("key = ?", key).First(&row).Error; err != nil { return "", false } return row.Value, true } // setSetting writes one main-store settings value (empty deletes the // row, hub's "empty clears" shape). var setSettingFn = func(key, value string) error { db := database.GetDB() if value == "" { return db.Where("key = ?", key).Delete(&model.Setting{}).Error } var row model.Setting err := db.Where("key = ?", key).First(&row).Error if database.IsNotFound(err) { return db.Create(&model.Setting{Key: key, Value: value}).Error } if err != nil { return err } row.Value = value return db.Save(&row).Error } // number reads a numeric notify setting the hub way: parse, range // check, default on anything else. func number(key string) int64 { for _, n := range numbersTable { if n.key == key { if v, ok := settingFn(key); ok { if parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && parsed >= n.min && parsed <= n.max { return parsed } } return n.def } } return 0 } // templateSetting is a template setting, where empty means default. func templateSetting(key, def string) string { if v, ok := settingFn(key); ok && v != "" { return v } return def } // loginEnabled reports the notify_login switch (default on, hub). func loginEnabled() bool { v, _ := settingFn("notify_login") return v != "off" } // siteName is the {{site}} placeholder's value: the public page's // site_name setting, "Monitor" by default (hub site_name()). func siteName() string { if v, ok := settingFn("site_name"); ok && v != "" { return v } return "Monitor" } // SettingError reports why a notification setting value cannot be // stored, or "" when it can — the hub's setting_error for the notify_* // keys, minus the Telegram token/chat pair (the panel bot owns those // credentials; see the package comment) and with the same texts. func SettingError(key, value string) string { for _, n := range numbersTable { if n.key == key { parsed, err := strconv.ParseInt(value, 10, 64) if err != nil || parsed < n.min || parsed > n.max { return fmt.Sprintf("%s要填 %d 到 %d 之间的整数", n.label, n.min, n.max) } return "" } } switch key { case "notify_login": if value != "on" && value != "off" { return "登录提醒只能是 on 或 off" } case "notify_webhook_headers": _, err := parseHeaders(value) return err case "notify_telegram_text", "notify_webhook_url", "notify_webhook_body": // Empty clears the channel field, or restores a template's // default. if value == "" { return "" } switch key { case "notify_telegram_text": // Any text goes; placeholders substitute at send time. case "notify_webhook_url": if !validWebhookURL(value) { return "Webhook URL 必须以 http:// 或 https:// 开头" } case "notify_webhook_body": var v any if err := json.Unmarshal([]byte(render(value, sampleNote(), "s\"i\\te", true)), &v); err != nil { return "Webhook 请求体填入内容后必须是合法的 JSON,占位符要放在引号里" } } default: return fmt.Sprintf("没有这个设置项:%s", key) } return "" } func validWebhookURL(value string) bool { return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") } // sampleNote carries values that exercise every escape the body // template must survive (hub sample()). func sampleNote() Note { return Note{ Event: "test", Node: "a\"b\\c", Title: "{{message}}", Message: "line\nline", } } // parseHeaders parses "Name: value", one per line. Values are never // echoed in an error: they are typically the credential. func parseHeaders(text string) ([][2]string, string) { var out [][2]string for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) if line == "" { continue } name, value, found := strings.Cut(line, ":") if !found { return nil, "Webhook 请求头每行要写成「名称: 值」" } name = strings.TrimSpace(name) value = strings.TrimSpace(value) if !validHeaderName(name) { return nil, fmt.Sprintf("Webhook 请求头名称 %q 不合法", name) } if strings.ContainsAny(value, "\r\n") { return nil, fmt.Sprintf("Webhook 请求头 %s 的值不合法", name) } out = append(out, [2]string{name, value}) } return out, "" } // validHeaderName is RFC 7230 token. func validHeaderName(name string) bool { if name == "" { return false } for _, r := range name { if r <= ' ' || r >= 0x7f || strings.ContainsRune("()<>@,;:\\\"/[]?={}", r) { return false } } return true } // render substitutes the placeholders in a single pass, JSON-escaping // each value when jsonEsc is set so it is valid inside a string // literal. A second pass would also substitute placeholders contained // in the inserted values, such as a node name (hub render()). func render(tmpl string, note Note, site string, jsonEsc bool) string { fields := []struct{ key, value string }{ {"{{event}}", note.Event}, {"{{node}}", note.Node}, {"{{title}}", note.Title}, {"{{message}}", note.Message}, {"{{site}}", site}, {"{{time}}", clock(note.Time)}, } var out strings.Builder out.Grow(len(tmpl)) rest := tmpl for { at := strings.Index(rest, "{{") if at < 0 { out.WriteString(rest) return out.String() } out.WriteString(rest[:at]) rest = rest[at:] matched := false for _, f := range fields { if strings.HasPrefix(rest, f.key) { if jsonEsc { quoted, _ := json.Marshal(f.value) out.Write(quoted[1 : len(quoted)-1]) } else { out.WriteString(f.value) } rest = rest[len(f.key):] matched = true break } } if !matched { out.WriteString("{{") rest = rest[2:] } } } // batch builds one alert for any number of nodes: a list in the // message, the names in Note.Node. ok is false when there is nothing // to say (hub batch()). func batch(event, mark, what string, items []batchItem) (Note, bool) { if len(items) == 0 { return Note{}, false } var title, message string if len(items) == 1 { title = fmt.Sprintf("%s %s %s", mark, items[0].name, what) message = items[0].detail } else { lines := make([]string, 0, listedMax+1) for i, it := range items { if i >= listedMax { break } lines = append(lines, fmt.Sprintf("%s · %s", it.name, it.detail)) } if len(items) > listedMax { lines = append(lines, fmt.Sprintf("……另外 %d 台", len(items)-listedMax)) } title = fmt.Sprintf("%s %d 台节点%s", mark, len(items), what) message = strings.Join(lines, "\n") } names := make([]string, len(items)) for i, it := range items { names[i] = it.name } return Note{Event: event, Node: strings.Join(names, ", "), Title: title, Message: message}, true } type batchItem struct { name, detail string } // span renders a duration the hub way ("3 分钟" / "2 小时 5 分钟" / // "1 天 2 小时"). func span(seconds int64) string { if seconds < 0 { seconds = 0 } minutes := seconds / 60 d, h, m := minutes/1440, minutes/60%24, minutes%60 switch { case d == 0 && h == 0: return fmt.Sprintf("%d 分钟", m) case d == 0: return fmt.Sprintf("%d 小时 %d 分钟", h, m) default: return fmt.Sprintf("%d 天 %d 小时", d, h) } } // clock formats a unix stamp in local time with its offset: a master // in a container commonly runs on UTC while its operator reads local // time (hub clock()). func clock(ts int64) string { if ts == 0 { return "" } return time.Unix(ts, 0).Format("01-02 15:04 -07:00") } // gib renders bytes as GiB, labelled GB as in the panel (hub gib()). func gib(bytes int64) string { return fmt.Sprintf("%.2f GB", float64(bytes)/float64(int64(1)<<30)) }