feat: rewrite notification system and telegram interactive bot

This commit is contained in:
Bot
2026-04-28 00:04:29 +08:00
parent c606fd99f6
commit 3319d7344f
11 changed files with 387 additions and 26 deletions
+1
View File
@@ -156,6 +156,7 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
auth.POST("/domains", commonHandler(AddDomain)) auth.POST("/domains", commonHandler(AddDomain))
auth.POST("/domains/:id/verify", commonHandler(VerifyDomain)) auth.POST("/domains/:id/verify", commonHandler(VerifyDomain))
auth.POST("/domains/:id/sync", commonHandler(SyncDomainWHOIS)) auth.POST("/domains/:id/sync", commonHandler(SyncDomainWHOIS))
auth.POST("/domains/sync-all", commonHandler(SyncAllDomains))
auth.PUT("/domains/:id", commonHandler(UpdateDomain)) auth.PUT("/domains/:id", commonHandler(UpdateDomain))
auth.DELETE("/domains/:id", commonHandler(DeleteDomain)) auth.DELETE("/domains/:id", commonHandler(DeleteDomain))
+5
View File
@@ -137,3 +137,8 @@ func SyncDomainWHOIS(c *gin.Context) (any, error) {
return domain, nil return domain, nil
} }
func SyncAllDomains(c *gin.Context) (any, error) {
singleton.SyncAllDomains()
return nil, nil
}
+2
View File
@@ -57,6 +57,7 @@ func createNotification(c *gin.Context) (uint64, error) {
n.RequestHeader = nf.RequestHeader n.RequestHeader = nf.RequestHeader
n.RequestBody = nf.RequestBody n.RequestBody = nf.RequestBody
n.URL = nf.URL n.URL = nf.URL
n.Type = nf.Type
verifyTLS := nf.VerifyTLS verifyTLS := nf.VerifyTLS
n.VerifyTLS = &verifyTLS n.VerifyTLS = &verifyTLS
formatMetricUnits := nf.FormatMetricUnits formatMetricUnits := nf.FormatMetricUnits
@@ -120,6 +121,7 @@ func updateNotification(c *gin.Context) (any, error) {
n.RequestHeader = nf.RequestHeader n.RequestHeader = nf.RequestHeader
n.RequestBody = nf.RequestBody n.RequestBody = nf.RequestBody
n.URL = nf.URL n.URL = nf.URL
n.Type = nf.Type
verifyTLS := nf.VerifyTLS verifyTLS := nf.VerifyTLS
n.VerifyTLS = &verifyTLS n.VerifyTLS = &verifyTLS
formatMetricUnits := nf.FormatMetricUnits formatMetricUnits := nf.FormatMetricUnits
+2 -2
View File
@@ -111,8 +111,8 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.Conf.CustomLogo = sf.CustomLogo singleton.Conf.CustomLogo = sf.CustomLogo
singleton.Conf.CustomDescription = sf.CustomDescription singleton.Conf.CustomDescription = sf.CustomDescription
singleton.Conf.CustomLinks = sf.CustomLinks singleton.Conf.CustomLinks = sf.CustomLinks
singleton.Conf.BackgroundImageDay = sf.BackgroundImageDay singleton.Conf.TelegramBotToken = sf.TelegramBotToken
singleton.Conf.BackgroundImageNight = sf.BackgroundImageNight singleton.Conf.TelegramAdminChatID = sf.TelegramAdminChatID
if err := singleton.Conf.Save(); err != nil { if err := singleton.Conf.Save(); err != nil {
return nil, newGormError("%v", err) return nil, newGormError("%v", err)
+4 -2
View File
@@ -43,7 +43,6 @@ type ConfigDashboard struct {
AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"` AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"`
EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码 EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码
ExpiryNotificationGroupID uint64 `koanf:"expiry_notification_group_id" json:"expiry_notification_group_id"`
// IP变更提醒 // IP变更提醒
EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"` EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"`
@@ -51,7 +50,10 @@ type ConfigDashboard struct {
Cover uint8 `koanf:"cover" json:"cover"` // 覆盖范围(0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器; Cover uint8 `koanf:"cover" json:"cover"` // 覆盖范围(0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器;
IgnoredIPNotification string `koanf:"ignored_ip_notification" json:"ignored_ip_notification,omitempty"` // 特定服务器IP(多个服务器用逗号分隔) IgnoredIPNotification string `koanf:"ignored_ip_notification" json:"ignored_ip_notification,omitempty"` // 特定服务器IP(多个服务器用逗号分隔)
DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"` DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"`
ExpiryNotificationGroupID uint64 `koanf:"expiry_notification_group_id" json:"expiry_notification_group_id,omitempty"`
TelegramBotToken string `koanf:"telegram_bot_token" json:"telegram_bot_token,omitempty"`
TelegramAdminChatID string `koanf:"telegram_admin_chat_id" json:"telegram_admin_chat_id,omitempty"`
} }
type Config struct { type Config struct {
+127 -16
View File
@@ -1,9 +1,12 @@
package model package model
import ( import (
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"html"
"io" "io"
"net"
"net/http" "net/http"
"net/smtp" "net/smtp"
"net/url" "net/url"
@@ -176,9 +179,6 @@ func (ns *NotificationServerBundle) Send(message string) error {
func (ns *NotificationServerBundle) sendSMTP(message string) error { func (ns *NotificationServerBundle) sendSMTP(message string) error {
n := ns.Notification n := ns.Notification
// RequestHeader: user:pass
// RequestBody: to_email
// URL: host:port
authInfo := strings.SplitN(n.RequestHeader, ":", 2) authInfo := strings.SplitN(n.RequestHeader, ":", 2)
if len(authInfo) < 2 { if len(authInfo) < 2 {
return errors.New("SMTP认证信息格式错误 (user:pass)") return errors.New("SMTP认证信息格式错误 (user:pass)")
@@ -187,31 +187,129 @@ func (ns *NotificationServerBundle) sendSMTP(message string) error {
pass := authInfo[1] pass := authInfo[1]
to := n.RequestBody to := n.RequestBody
hp := strings.SplitN(n.URL, ":", 2) host, port, err := net.SplitHostPort(n.URL)
if len(hp) < 2 { if err != nil {
return errors.New("SMTP服务器地址格式错误 (host:port)") return errors.New("SMTP服务器地址格式错误 (host:port)")
} }
auth := smtp.PlainAuth("", user, pass, hp[0])
subject := "Nezha Monitoring Alert" subject := "Nezha Monitoring Alert"
if ns.Server != nil { if ns.Server != nil {
subject = fmt.Sprintf("Nezha Alert: %s", ns.Server.Name) subject = fmt.Sprintf("Nezha Alert: %s", ns.Server.Name)
} }
body := fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s", to, subject, message) // 提取真实的发件人邮箱 (处理 username != email 的情况)
fromEmail := user
err := smtp.SendMail(n.URL, auth, user, []string{to}, []byte(body)) if !strings.Contains(user, "@") {
if err != nil { // 如果用户名不是邮箱,为了防止被拦截,构造一个合法的From
return err fromEmail = fmt.Sprintf("nezha@%s", host)
} }
// 遵循 RFC 2822
header := make(map[string]string)
header["From"] = fmt.Sprintf("Nezha Monitoring <%s>", fromEmail)
header["To"] = to
header["Subject"] = subject
header["Date"] = time.Now().Format(time.RFC1123Z)
header["Content-Type"] = "text/plain; charset=UTF-8"
var msg strings.Builder
for k, v := range header {
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
}
msg.WriteString("\r\n")
msg.WriteString(message)
tlsConfig := &tls.Config{
InsecureSkipVerify: n.VerifyTLS == nil || !*n.VerifyTLS,
ServerName: host,
}
auth := smtp.PlainAuth("", user, pass, host)
if port == "465" {
// SMTPS (Implicit SSL)
conn, err := tls.Dial("tcp", n.URL, tlsConfig)
if err != nil {
return fmt.Errorf("SMTP SSL Dial error: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("SMTP NewClient error: %w", err)
}
defer client.Quit()
if err = client.Auth(auth); err != nil {
return fmt.Errorf("SMTP Auth error: %w", err)
}
if err = client.Mail(fromEmail); err != nil {
return fmt.Errorf("SMTP Mail error: %w", err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP Rcpt error: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP Data error: %w", err)
}
_, err = w.Write([]byte(msg.String()))
if err != nil {
return fmt.Errorf("SMTP Write error: %w", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("SMTP Close error: %w", err)
}
return nil
}
// STARTTLS (Port 25, 587, etc.)
conn, err := net.Dial("tcp", n.URL)
if err != nil {
return fmt.Errorf("SMTP Dial error: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("SMTP NewClient error: %w", err)
}
defer client.Quit()
if ok, _ := client.Extension("STARTTLS"); ok {
if err = client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("SMTP StartTLS error: %w", err)
}
}
if err = client.Auth(auth); err != nil {
return fmt.Errorf("SMTP Auth error: %w", err)
}
if err = client.Mail(fromEmail); err != nil {
return fmt.Errorf("SMTP Mail error: %w", err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP Rcpt error: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP Data error: %w", err)
}
_, err = w.Write([]byte(msg.String()))
if err != nil {
return fmt.Errorf("SMTP Write error: %w", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("SMTP Close error: %w", err)
}
return nil return nil
} }
func (ns *NotificationServerBundle) sendTelegram(message string) error { func (ns *NotificationServerBundle) sendTelegram(message string) error {
n := ns.Notification n := ns.Notification
// URL: bot_token
// RequestHeader: chat_id
token := n.URL token := n.URL
chatID := n.RequestHeader chatID := n.RequestHeader
@@ -219,10 +317,23 @@ func (ns *NotificationServerBundle) sendTelegram(message string) error {
params := url.Values{} params := url.Values{}
params.Add("chat_id", chatID) params.Add("chat_id", chatID)
params.Add("text", message) params.Add("text", html.EscapeString(message))
params.Add("parse_mode", "HTML") params.Add("parse_mode", "HTML")
resp, err := http.PostForm(apiURL, params) var client *http.Client
if n.VerifyTLS != nil && *n.VerifyTLS {
client = utils.HttpClient
} else {
client = utils.HttpClientSkipTlsVerify
}
req, err := http.NewRequest(http.MethodPost, apiURL, strings.NewReader(params.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil { if err != nil {
return err return err
} }
+1
View File
@@ -10,4 +10,5 @@ type NotificationForm struct {
VerifyTLS bool `json:"verify_tls,omitempty" validate:"optional"` VerifyTLS bool `json:"verify_tls,omitempty" validate:"optional"`
SkipCheck bool `json:"skip_check,omitempty" validate:"optional"` SkipCheck bool `json:"skip_check,omitempty" validate:"optional"`
FormatMetricUnits bool `json:"format_metric_units,omitempty" validate:"optional"` FormatMetricUnits bool `json:"format_metric_units,omitempty" validate:"optional"`
Type uint8 `json:"type,omitempty" validate:"optional"`
} }
+7 -5
View File
@@ -17,12 +17,14 @@ type SettingForm struct {
CustomDescription string `json:"custom_description,omitempty" validate:"optional"` CustomDescription string `json:"custom_description,omitempty" validate:"optional"`
CustomLinks string `json:"custom_links,omitempty" validate:"optional"` CustomLinks string `json:"custom_links,omitempty" validate:"optional"`
BackgroundImageDay string `json:"background_image_day,omitempty" validate:"optional"` BackgroundImageDay string `json:"background_image_day,omitempty" validate:"optional"`
BackgroundImageNight string `json:"background_image_night,omitempty" validate:"optional"` BackgroundImageNight string `json:"background_image_night,omitempty" validate:"optional"`
AgentTLS bool `json:"tls,omitempty" validate:"optional"` AgentTLS bool `json:"tls,omitempty" validate:"optional"`
EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"` EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"`
EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"` EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"`
ExpiryNotificationGroupID uint64 `json:"expiry_notification_group_id,omitempty" validate:"optional"` ExpiryNotificationGroupID uint64 `json:"expiry_notification_group_id,omitempty"`
TelegramBotToken string `json:"telegram_bot_token,omitempty" validate:"optional"`
TelegramAdminChatID string `json:"telegram_admin_chat_id,omitempty" validate:"optional"`
} }
type Setting struct { type Setting struct {
+27 -1
View File
@@ -195,6 +195,32 @@ func SyncDomainWHOIS(d *model.Domain) error {
return nil return nil
} }
// SyncAllDomains 异步批量同步所有已验证域名的 Whois 和价格信息
func SyncAllDomains() {
go func() {
log.Println("NEZHA>> 开始批量同步所有域名的 Whois 和价格信息...")
domains, err := GetDomains("admin")
if err != nil {
log.Printf("NEZHA>> 批量同步域名失败: %v", err)
return
}
successCount := 0
for _, d := range domains {
if d.Status == "verified" {
if err := SyncDomainWHOIS(&d); err != nil {
log.Printf("NEZHA>> 域名 %s 同步失败: %v", d.Domain, err)
} else {
successCount++
}
// 避免并发过高被 API 限制
time.Sleep(2 * time.Second)
}
}
log.Printf("NEZHA>> 批量同步域名结束,成功 %d/%d", successCount, len(domains))
}()
}
// GetDomains 获取所有域名记录 // GetDomains 获取所有域名记录
func GetDomains(scope string) ([]model.Domain, error) { func GetDomains(scope string) ([]model.Domain, error) {
var domains []model.Domain var domains []model.Domain
@@ -354,7 +380,7 @@ func CronJobForDomainStatus() {
msg := "" msg := ""
switch daysLeft + 1 { switch daysLeft + 1 {
case 60, 30, 15, 7, 3, 1: case 60, 30, 15, 7, 3, 1:
msg = fmt.Sprintf("域名 [%s] 即将到期,剩余 %d 天。到期时间: %s", d.Domain, daysLeft+1, endDate.Format("2006-01-02")) msg = fmt.Sprintf("域名 [%s] 即通知期,剩余 %d 天。到期时间: %s", d.Domain, daysLeft+1, endDate.Format("2006-01-02"))
case 0: case 0:
msg = fmt.Sprintf("域名 [%s] 已到期!到期时间: %s", d.Domain, endDate.Format("2006-01-02")) msg = fmt.Sprintf("域名 [%s] 已到期!到期时间: %s", d.Domain, endDate.Format("2006-01-02"))
} }
+3
View File
@@ -61,6 +61,9 @@ func LoadSingleton(bus chan<- *model.Service) (err error) {
CronShared = NewCronClass() CronShared = NewCronClass()
// 最后初始化 ServiceSentinel // 最后初始化 ServiceSentinel
ServiceSentinelShared, err = NewServiceSentinel(bus) ServiceSentinelShared, err = NewServiceSentinel(bus)
if err == nil {
InitTelegramBot()
}
return return
} }
+208
View File
@@ -0,0 +1,208 @@
package singleton
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
)
type tgUpdate struct {
UpdateID int `json:"update_id"`
Message *struct {
MessageID int `json:"message_id"`
From *struct {
ID int64 `json:"id"`
} `json:"from"`
Chat *struct {
ID int64 `json:"id"`
} `json:"chat"`
Text string `json:"text"`
} `json:"message"`
}
func InitTelegramBot() {
if Conf.TelegramBotToken == "" {
log.Println("NEZHA>> TG Bot Token 未配置,跳过启动互动机器人")
return
}
log.Println("NEZHA>> 正在启动 Telegram 互动机器人...")
go func() {
offset := 0
for {
updates, err := getTGUpdates(Conf.TelegramBotToken, offset)
if err != nil {
// 避免过于频繁报错
time.Sleep(30 * time.Second)
continue
}
for _, update := range updates {
offset = update.UpdateID + 1
if update.Message != nil {
handleTGUpdate(update)
}
}
time.Sleep(3 * time.Second)
}
}()
}
func getTGUpdates(token string, offset int) ([]tgUpdate, error) {
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?offset=%d&timeout=20", token, offset)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
resp, err := utils.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
OK bool `json:"ok"`
Result []tgUpdate `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Result, nil
}
func handleTGUpdate(update tgUpdate) {
if update.Message == nil || update.Message.Chat == nil {
return
}
chatID := update.Message.Chat.ID
adminChatID, _ := strconv.ParseInt(Conf.TelegramAdminChatID, 10, 64)
// 权限检查
if adminChatID != 0 && chatID != adminChatID {
sendTGMessage(chatID, "🚫 您没有权限操作此机器人。")
return
}
text := update.Message.Text
switch {
case text == "/start" || text == "/help":
sendTGMainMenu(chatID)
case text == "/status" || text == "📊 运行状态":
sendTGStatus(chatID)
case text == "/domains" || text == "🌐 域名监控":
sendTGDomains(chatID)
default:
if strings.HasPrefix(text, "/") {
sendTGMessage(chatID, "❓ 未知命令,请输入 /start 查看菜单。")
}
}
}
func sendTGMainMenu(chatID int64) {
menu := "👋 您好!我是哪吒监控助手。\n\n请选择以下操作:"
keyboard := map[string]interface{}{
"keyboard": [][]map[string]string{
{{"text": "📊 运行状态"}, {"text": "🌐 域名监控"}},
},
"resize_keyboard": true,
}
kbJSON, _ := json.Marshal(keyboard)
sendTGRequest("sendMessage", url.Values{
"chat_id": {strconv.FormatInt(chatID, 10)},
"text": {menu},
"reply_markup": {string(kbJSON)},
})
}
func sendTGStatus(chatID int64) {
var sb strings.Builder
sb.WriteString("📊 <b>服务器实时状态</b>\n\n")
ServerShared.Range(func(id uint64, s *model.Server) bool {
statusIcon := "🟢"
if !s.LastActive.After(time.Now().Add(-time.Second * 30)) {
statusIcon = "🔴"
}
sb.WriteString(fmt.Sprintf("%s <b>%s</b>\n", statusIcon, s.Name))
sb.WriteString(fmt.Sprintf("├ CPU: %.1f%% | Mem: %.1f%%\n", s.State.CPU, float64(s.State.MemUsed)/float64(s.Host.MemTotal)*100))
sb.WriteString(fmt.Sprintf("└ Net: ↓%s/s ↑%s/s\n\n", utils.Bytes(s.State.NetInSpeed), utils.Bytes(s.State.NetOutSpeed)))
return true
})
if sb.Len() < 50 {
sb.WriteString("暂无在线服务器。")
}
sendTGMessage(chatID, sb.String())
}
func sendTGDomains(chatID int64) {
domains, err := GetDomains("admin")
if err != nil {
sendTGMessage(chatID, "❌ 获取域名列表失败。")
return
}
var sb strings.Builder
sb.WriteString("🌐 <b>域名监控状态</b>\n\n")
now := time.Now()
for _, d := range domains {
statusIcon := "✅"
if d.Status == "pending" {
statusIcon = "⏳"
} else if d.Status == "expired" {
statusIcon = "❌"
}
expiresInfo := "N/A"
if d.BillingData != nil {
var billing model.BillingDataMod
if json.Unmarshal(d.BillingData, &billing) == nil && billing.EndDate != "" {
if endDate, err := time.Parse(time.RFC3339, billing.EndDate); err == nil {
daysLeft := int(endDate.Sub(now).Hours() / 24)
expiresInfo = fmt.Sprintf("%d 天", daysLeft)
}
}
}
sb.WriteString(fmt.Sprintf("%s <b>%s</b>\n", statusIcon, d.Domain))
sb.WriteString(fmt.Sprintf("└ 剩余: %s | 状态: %s\n\n", expiresInfo, d.Status))
}
if len(domains) == 0 {
sb.WriteString("暂无监控中的域名。")
}
sendTGMessage(chatID, sb.String())
}
func sendTGMessage(chatID int64, text string) {
sendTGRequest("sendMessage", url.Values{
"chat_id": {strconv.FormatInt(chatID, 10)},
"text": {text},
"parse_mode": {"HTML"},
})
}
func sendTGRequest(method string, params url.Values) {
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/%s", Conf.TelegramBotToken, method)
req, err := http.NewRequest(http.MethodPost, apiURL, strings.NewReader(params.Encode()))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := utils.HttpClient.Do(req)
if err == nil {
resp.Body.Close()
}
}