From 3319d7344fac44b59150745dbd4d0d82d29b760b Mon Sep 17 00:00:00 2001 From: Bot Date: Tue, 28 Apr 2026 00:04:29 +0800 Subject: [PATCH] feat: rewrite notification system and telegram interactive bot --- cmd/dashboard/controller/controller.go | 1 + cmd/dashboard/controller/domain.go | 5 + cmd/dashboard/controller/notification.go | 2 + cmd/dashboard/controller/setting.go | 4 +- model/config.go | 6 +- model/notification.go | 143 ++++++++++++++-- model/notification_api.go | 1 + model/setting_api.go | 12 +- service/singleton/domain.go | 28 ++- service/singleton/singleton.go | 3 + service/singleton/telegram_bot.go | 208 +++++++++++++++++++++++ 11 files changed, 387 insertions(+), 26 deletions(-) create mode 100644 service/singleton/telegram_bot.go diff --git a/cmd/dashboard/controller/controller.go b/cmd/dashboard/controller/controller.go index 0cb1aae..0229b07 100644 --- a/cmd/dashboard/controller/controller.go +++ b/cmd/dashboard/controller/controller.go @@ -156,6 +156,7 @@ func routers(r *gin.Engine, frontendDist fs.FS) { auth.POST("/domains", commonHandler(AddDomain)) auth.POST("/domains/:id/verify", commonHandler(VerifyDomain)) auth.POST("/domains/:id/sync", commonHandler(SyncDomainWHOIS)) + auth.POST("/domains/sync-all", commonHandler(SyncAllDomains)) auth.PUT("/domains/:id", commonHandler(UpdateDomain)) auth.DELETE("/domains/:id", commonHandler(DeleteDomain)) diff --git a/cmd/dashboard/controller/domain.go b/cmd/dashboard/controller/domain.go index b15b8be..3216a28 100644 --- a/cmd/dashboard/controller/domain.go +++ b/cmd/dashboard/controller/domain.go @@ -137,3 +137,8 @@ func SyncDomainWHOIS(c *gin.Context) (any, error) { return domain, nil } + +func SyncAllDomains(c *gin.Context) (any, error) { + singleton.SyncAllDomains() + return nil, nil +} diff --git a/cmd/dashboard/controller/notification.go b/cmd/dashboard/controller/notification.go index 774ecf2..a40a706 100644 --- a/cmd/dashboard/controller/notification.go +++ b/cmd/dashboard/controller/notification.go @@ -57,6 +57,7 @@ func createNotification(c *gin.Context) (uint64, error) { n.RequestHeader = nf.RequestHeader n.RequestBody = nf.RequestBody n.URL = nf.URL + n.Type = nf.Type verifyTLS := nf.VerifyTLS n.VerifyTLS = &verifyTLS formatMetricUnits := nf.FormatMetricUnits @@ -120,6 +121,7 @@ func updateNotification(c *gin.Context) (any, error) { n.RequestHeader = nf.RequestHeader n.RequestBody = nf.RequestBody n.URL = nf.URL + n.Type = nf.Type verifyTLS := nf.VerifyTLS n.VerifyTLS = &verifyTLS formatMetricUnits := nf.FormatMetricUnits diff --git a/cmd/dashboard/controller/setting.go b/cmd/dashboard/controller/setting.go index a3d1fd6..8d3b1cb 100644 --- a/cmd/dashboard/controller/setting.go +++ b/cmd/dashboard/controller/setting.go @@ -111,8 +111,8 @@ func updateConfig(c *gin.Context) (any, error) { singleton.Conf.CustomLogo = sf.CustomLogo singleton.Conf.CustomDescription = sf.CustomDescription singleton.Conf.CustomLinks = sf.CustomLinks - singleton.Conf.BackgroundImageDay = sf.BackgroundImageDay - singleton.Conf.BackgroundImageNight = sf.BackgroundImageNight + singleton.Conf.TelegramBotToken = sf.TelegramBotToken + singleton.Conf.TelegramAdminChatID = sf.TelegramAdminChatID if err := singleton.Conf.Save(); err != nil { return nil, newGormError("%v", err) diff --git a/model/config.go b/model/config.go index 37962bc..65d8b7c 100644 --- a/model/config.go +++ b/model/config.go @@ -43,7 +43,6 @@ type ConfigDashboard struct { 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不打码 - ExpiryNotificationGroupID uint64 `koanf:"expiry_notification_group_id" json:"expiry_notification_group_id"` // IP变更提醒 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 包含的服务器;) 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 { diff --git a/model/notification.go b/model/notification.go index 983251d..d8e156d 100644 --- a/model/notification.go +++ b/model/notification.go @@ -1,9 +1,12 @@ package model import ( + "crypto/tls" "errors" "fmt" + "html" "io" + "net" "net/http" "net/smtp" "net/url" @@ -176,9 +179,6 @@ func (ns *NotificationServerBundle) Send(message string) error { func (ns *NotificationServerBundle) sendSMTP(message string) error { n := ns.Notification - // RequestHeader: user:pass - // RequestBody: to_email - // URL: host:port authInfo := strings.SplitN(n.RequestHeader, ":", 2) if len(authInfo) < 2 { return errors.New("SMTP认证信息格式错误 (user:pass)") @@ -187,31 +187,129 @@ func (ns *NotificationServerBundle) sendSMTP(message string) error { pass := authInfo[1] to := n.RequestBody - hp := strings.SplitN(n.URL, ":", 2) - if len(hp) < 2 { + host, port, err := net.SplitHostPort(n.URL) + if err != nil { return errors.New("SMTP服务器地址格式错误 (host:port)") } - auth := smtp.PlainAuth("", user, pass, hp[0]) - subject := "Nezha Monitoring Alert" if ns.Server != nil { 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) - - err := smtp.SendMail(n.URL, auth, user, []string{to}, []byte(body)) - if err != nil { - return err + // 提取真实的发件人邮箱 (处理 username != email 的情况) + fromEmail := user + if !strings.Contains(user, "@") { + // 如果用户名不是邮箱,为了防止被拦截,构造一个合法的From + 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 } func (ns *NotificationServerBundle) sendTelegram(message string) error { n := ns.Notification - // URL: bot_token - // RequestHeader: chat_id token := n.URL chatID := n.RequestHeader @@ -219,10 +317,23 @@ func (ns *NotificationServerBundle) sendTelegram(message string) error { params := url.Values{} params.Add("chat_id", chatID) - params.Add("text", message) + params.Add("text", html.EscapeString(message)) 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 { return err } diff --git a/model/notification_api.go b/model/notification_api.go index a30971e..e9e6d5a 100644 --- a/model/notification_api.go +++ b/model/notification_api.go @@ -10,4 +10,5 @@ type NotificationForm struct { VerifyTLS bool `json:"verify_tls,omitempty" validate:"optional"` SkipCheck bool `json:"skip_check,omitempty" validate:"optional"` FormatMetricUnits bool `json:"format_metric_units,omitempty" validate:"optional"` + Type uint8 `json:"type,omitempty" validate:"optional"` } diff --git a/model/setting_api.go b/model/setting_api.go index d62c54a..b1e664c 100644 --- a/model/setting_api.go +++ b/model/setting_api.go @@ -17,12 +17,14 @@ type SettingForm struct { CustomDescription string `json:"custom_description,omitempty" validate:"optional"` CustomLinks string `json:"custom_links,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"` - EnableIPChangeNotification bool `json:"enable_ip_change_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"` + AgentTLS bool `json:"tls,omitempty" validate:"optional"` + EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"` + EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,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 { diff --git a/service/singleton/domain.go b/service/singleton/domain.go index 804ddab..5c7109b 100644 --- a/service/singleton/domain.go +++ b/service/singleton/domain.go @@ -195,6 +195,32 @@ func SyncDomainWHOIS(d *model.Domain) error { 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 获取所有域名记录 func GetDomains(scope string) ([]model.Domain, error) { var domains []model.Domain @@ -354,7 +380,7 @@ func CronJobForDomainStatus() { msg := "" switch daysLeft + 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: msg = fmt.Sprintf("域名 [%s] 已到期!到期时间: %s", d.Domain, endDate.Format("2006-01-02")) } diff --git a/service/singleton/singleton.go b/service/singleton/singleton.go index 694f336..66fa982 100644 --- a/service/singleton/singleton.go +++ b/service/singleton/singleton.go @@ -61,6 +61,9 @@ func LoadSingleton(bus chan<- *model.Service) (err error) { CronShared = NewCronClass() // 最后初始化 ServiceSentinel ServiceSentinelShared, err = NewServiceSentinel(bus) + if err == nil { + InitTelegramBot() + } return } diff --git a/service/singleton/telegram_bot.go b/service/singleton/telegram_bot.go new file mode 100644 index 0000000..adb950d --- /dev/null +++ b/service/singleton/telegram_bot.go @@ -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("📊 服务器实时状态\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 %s\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("🌐 域名监控状态\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 %s\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() + } +}