diff --git a/.gitignore b/.gitignore index 1771ab9..c798361 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ /resource/template/theme-custom /resource/static/custom /cmd/dashboard/docs -/data/* \ No newline at end of file +/data/* +app +dashboard \ No newline at end of file diff --git a/cmd/dashboard/controller/setting.go b/cmd/dashboard/controller/setting.go index 8d3b1cb..ccc02a1 100644 --- a/cmd/dashboard/controller/setting.go +++ b/cmd/dashboard/controller/setting.go @@ -113,6 +113,12 @@ func updateConfig(c *gin.Context) (any, error) { singleton.Conf.CustomLinks = sf.CustomLinks singleton.Conf.TelegramBotToken = sf.TelegramBotToken singleton.Conf.TelegramAdminChatID = sf.TelegramAdminChatID + singleton.Conf.SMTPServer = sf.SMTPServer + singleton.Conf.SMTPUser = sf.SMTPUser + singleton.Conf.SMTPPassword = sf.SMTPPassword + singleton.Conf.AdminEmail = sf.AdminEmail + singleton.Conf.DomainExpiryNotificationDays = sf.DomainExpiryNotificationDays + singleton.Conf.ServerExpiryNotificationDays = sf.ServerExpiryNotificationDays if err := singleton.Conf.Save(); err != nil { return nil, newGormError("%v", err) diff --git a/model/config.go b/model/config.go index 65d8b7c..22a9bec 100644 --- a/model/config.go +++ b/model/config.go @@ -54,6 +54,13 @@ type ConfigDashboard struct { 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"` + + SMTPServer string `koanf:"smtp_server" json:"smtp_server,omitempty"` + SMTPUser string `koanf:"smtp_user" json:"smtp_user,omitempty"` + SMTPPassword string `koanf:"smtp_password" json:"smtp_password,omitempty"` + AdminEmail string `koanf:"admin_email" json:"admin_email,omitempty"` + DomainExpiryNotificationDays string `koanf:"domain_expiry_notification_days" json:"domain_expiry_notification_days,omitempty"` + ServerExpiryNotificationDays string `koanf:"server_expiry_notification_days" json:"server_expiry_notification_days,omitempty"` } type Config struct { diff --git a/model/notification.go b/model/notification.go index d8e156d..a9857f6 100644 --- a/model/notification.go +++ b/model/notification.go @@ -1,14 +1,10 @@ package model import ( - "crypto/tls" "errors" "fmt" - "html" "io" - "net" "net/http" - "net/smtp" "net/url" "strings" "time" @@ -38,10 +34,15 @@ type NotificationServerBundle struct { const ( _ = iota NotificationTypeWebhook - NotificationTypeSMTP + NotificationTypeEmail NotificationTypeTelegram ) +var ( + SendGlobalTelegramFunc func(message string) error + SendGlobalEmailFunc func(message string) error +) + type Notification struct { Common Name string `json:"name"` @@ -49,8 +50,8 @@ type Notification struct { URL string `json:"url"` // SMTP: host:port, Webhook: url, Telegram: bot_token RequestMethod uint8 `json:"request_method"` RequestType uint8 `json:"request_type"` - RequestHeader string `json:"request_header" gorm:"type:longtext"` // SMTP: user:pass, Webhook: header, Telegram: chat_id - RequestBody string `json:"request_body" gorm:"type:longtext"` // SMTP: recipient, Webhook: body, Telegram: (ignored) + RequestHeader string `json:"request_header" gorm:"type:longtext"` // Webhook: header + RequestBody string `json:"request_body" gorm:"type:longtext"` // Webhook: body VerifyTLS *bool `json:"verify_tls,omitempty"` FormatMetricUnits *bool `json:"format_metric_units,omitempty"` } @@ -124,11 +125,21 @@ func (n *Notification) setRequestHeader(req *http.Request) error { func (ns *NotificationServerBundle) Send(message string) error { n := ns.Notification - if n.Type == NotificationTypeSMTP { - return ns.sendSMTP(message) - } - if n.Type == NotificationTypeTelegram { - return ns.sendTelegram(message) + + if n.Type == NotificationTypeEmail || n.Type == NotificationTypeTelegram { + template := n.RequestBody + if template == "" { + template = message + } + content := ns.replaceParamsInString(template, message, nil) + + if n.Type == NotificationTypeEmail && SendGlobalEmailFunc != nil { + return SendGlobalEmailFunc(content) + } + if n.Type == NotificationTypeTelegram && SendGlobalTelegramFunc != nil { + return SendGlobalTelegramFunc(content) + } + return nil } var client *http.Client @@ -177,175 +188,9 @@ func (ns *NotificationServerBundle) Send(message string) error { return nil } -func (ns *NotificationServerBundle) sendSMTP(message string) error { - n := ns.Notification - authInfo := strings.SplitN(n.RequestHeader, ":", 2) - if len(authInfo) < 2 { - return errors.New("SMTP认证信息格式错误 (user:pass)") - } - user := authInfo[0] - pass := authInfo[1] - to := n.RequestBody - host, port, err := net.SplitHostPort(n.URL) - if err != nil { - return errors.New("SMTP服务器地址格式错误 (host:port)") - } - subject := "Nezha Monitoring Alert" - if ns.Server != nil { - subject = fmt.Sprintf("Nezha Alert: %s", ns.Server.Name) - } - // 提取真实的发件人邮箱 (处理 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 - token := n.URL - chatID := n.RequestHeader - - apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token) - - params := url.Values{} - params.Add("chat_id", chatID) - params.Add("text", html.EscapeString(message)) - params.Add("parse_mode", "HTML") - - 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 - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("Telegram API Error (%d): %s", resp.StatusCode, string(body)) - } - - return nil -} // replaceParamInString 替换字符串中的占位符 func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string { @@ -359,9 +204,35 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st } if ns.Server != nil { + var noteData struct { + BillingDataMod struct { + EndDate string `json:"endDate"` + Amount string `json:"amount"` + Cycle string `json:"cycle"` + } `json:"billingDataMod"` + } + expiresStr := "" + amountStr := "" + cycleStr := "" + + if ns.Server.Note != "" && json.Unmarshal([]byte(ns.Server.Note), ¬eData) == nil && noteData.BillingDataMod.EndDate != "" { + expiresStr = noteData.BillingDataMod.EndDate + amountStr = noteData.BillingDataMod.Amount + cycleStr = noteData.BillingDataMod.Cycle + } else if ns.Server.PublicNote != "" && json.Unmarshal([]byte(ns.Server.PublicNote), ¬eData) == nil && noteData.BillingDataMod.EndDate != "" { + expiresStr = noteData.BillingDataMod.EndDate + amountStr = noteData.BillingDataMod.Amount + cycleStr = noteData.BillingDataMod.Cycle + } + replacements = append(replacements, "#SERVER.NAME#", mod(ns.Server.Name), "#SERVER.ID#", mod(fmt.Sprintf("%d", ns.Server.ID)), + "#SERVER.NOTE#", mod(ns.Server.Note), + "#SERVER.PUBLIC_NOTE#", mod(ns.Server.PublicNote), + "#SERVER.EXPIRE_DATE#", mod(expiresStr), + "#SERVER.BILLING_AMOUNT#", mod(amountStr), + "#SERVER.BILLING_CYCLE#", mod(cycleStr), // Converted metrics "#SERVER.CPU#", mod(ns.formatUsage(false, ns.Server.State.CPU)), diff --git a/model/setting_api.go b/model/setting_api.go index b1e664c..bc00e17 100644 --- a/model/setting_api.go +++ b/model/setting_api.go @@ -25,6 +25,13 @@ type SettingForm struct { 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"` + + SMTPServer string `json:"smtp_server,omitempty" validate:"optional"` + SMTPUser string `json:"smtp_user,omitempty" validate:"optional"` + SMTPPassword string `json:"smtp_password,omitempty" validate:"optional"` + AdminEmail string `json:"admin_email,omitempty" validate:"optional"` + DomainExpiryNotificationDays string `json:"domain_expiry_notification_days,omitempty" validate:"optional"` + ServerExpiryNotificationDays string `json:"server_expiry_notification_days,omitempty" validate:"optional"` } type Setting struct { diff --git a/service/singleton/domain.go b/service/singleton/domain.go index 5c7109b..ea5aa3b 100644 --- a/service/singleton/domain.go +++ b/service/singleton/domain.go @@ -10,6 +10,8 @@ import ( "log" "net" "net/http" + "slices" + "strconv" "strings" "time" @@ -340,6 +342,36 @@ func DeleteDomain(id uint64) error { return DB.Delete(&model.Domain{}, id).Error } +func isDomainNotificationDay(daysLeft int) bool { + daysStr := Conf.DomainExpiryNotificationDays + if daysStr == "" { + return slices.Contains([]int{60, 30, 15, 7, 3, 1, 0}, daysLeft+1) + } + parts := strings.Split(daysStr, ",") + for _, p := range parts { + d, err := strconv.Atoi(strings.TrimSpace(p)) + if err == nil && d == daysLeft+1 { + return true + } + } + return false +} + +func isServerNotificationDay(daysLeft int) bool { + daysStr := Conf.ServerExpiryNotificationDays + if daysStr == "" { + return slices.Contains([]int{30, 15, 7, 3, 1, 0}, daysLeft+1) + } + parts := strings.Split(daysStr, ",") + for _, p := range parts { + d, err := strconv.Atoi(strings.TrimSpace(p)) + if err == nil && d == daysLeft+1 { + return true + } + } + return false +} + // CronJobForDomainStatus 检查域名到期和自动续费的定时任务 func CronJobForDomainStatus() { log.Println("NEZHA>> Cron::开始执行域名状态检查任务") @@ -376,17 +408,14 @@ func CronJobForDomainStatus() { daysLeft := int(endDate.Sub(now).Hours() / 24) // 只有在到期前一定天数通知,且避开重复通知 (简单逻辑:每天通知一次) - if Conf.ExpiryNotificationGroupID != 0 { + if Conf.ExpiryNotificationGroupID != 0 && isDomainNotificationDay(daysLeft) { 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")) - case 0: + if daysLeft+1 > 0 { + msg = fmt.Sprintf("域名 [%s] 即将到期,剩余 %d 天。到期时间: %s", d.Domain, daysLeft+1, endDate.Format("2006-01-02")) + } else { msg = fmt.Sprintf("域名 [%s] 已到期!到期时间: %s", d.Domain, endDate.Format("2006-01-02")) } - if msg != "" { - NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-domain-%d-%d", d.ID, daysLeft)) - } + NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-domain-%d-%d", d.ID, daysLeft)) } if now.After(endDate) { @@ -457,17 +486,14 @@ func CronJobForServerStatus() { daysLeft := int(endDate.Sub(now).Hours() / 24) - if Conf.ExpiryNotificationGroupID != 0 { + if Conf.ExpiryNotificationGroupID != 0 && isServerNotificationDay(daysLeft) { msg := "" - switch daysLeft + 1 { - case 30, 15, 7, 3, 1: + if daysLeft+1 > 0 { msg = fmt.Sprintf("VPS [%s] 即将到期,剩余 %d 天。到期时间: %s", s.Name, daysLeft+1, endDate.Format("2006-01-02")) - case 0: + } else { msg = fmt.Sprintf("VPS [%s] 已到期!到期时间: %s", s.Name, endDate.Format("2006-01-02")) } - if msg != "" { - NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-server-%d-%d", s.ID, daysLeft)) - } + NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-server-%d-%d", s.ID, daysLeft), &s) } } log.Println("NEZHA>> Cron::服务器到期检查任务执行完毕") diff --git a/service/singleton/notification.go b/service/singleton/notification.go index 0b358ab..28b1a9d 100644 --- a/service/singleton/notification.go +++ b/service/singleton/notification.go @@ -2,9 +2,15 @@ package singleton import ( "cmp" + "crypto/tls" "fmt" + "html" "log" + "net" + "net/smtp" "slices" + "strconv" + "strings" "sync" "time" @@ -16,6 +22,35 @@ const ( firstNotificationDelay = time.Minute * 15 ) +func init() { + model.SendGlobalTelegramFunc = func(message string) error { + if Conf.TelegramBotToken != "" && Conf.TelegramAdminChatID != "" { + adminChatID, err := strconv.ParseInt(Conf.TelegramAdminChatID, 10, 64) + if err == nil && adminChatID != 0 { + safeDesc := html.EscapeString(message) + msg := fmt.Sprintf("⚠️ Nezha 报警通知\n\n%s", safeDesc) + sendTGMessage(adminChatID, msg) + log.Printf("NEZHA>> Sent notification to Telegram Admin Bot") + return nil + } + } + return nil + } + + model.SendGlobalEmailFunc = func(message string) error { + if Conf.SMTPServer != "" && Conf.SMTPUser != "" && Conf.SMTPPassword != "" && Conf.AdminEmail != "" { + if err := sendSMTPAdminNotification(message); err != nil { + log.Printf("NEZHA>> Sending admin email notification failed: %v", err) + return err + } else { + log.Printf("NEZHA>> Sent notification to Admin Email") + return nil + } + } + return nil + } +} + type NotificationClass struct { class[uint64, *model.Notification] @@ -257,6 +292,113 @@ func (c *NotificationClass) SendNotification(notificationGroupID uint64, desc st } } +func sendSMTPAdminNotification(message string) error { + host, port, err := net.SplitHostPort(Conf.SMTPServer) + if err != nil { + return fmt.Errorf("SMTP服务器地址格式错误 (host:port)") + } + + fromEmail := Conf.SMTPUser + if !strings.Contains(Conf.SMTPUser, "@") { + fromEmail = fmt.Sprintf("nezha@%s", host) + } + + header := make(map[string]string) + header["From"] = fmt.Sprintf("Nezha Monitoring <%s>", fromEmail) + header["To"] = Conf.AdminEmail + header["Subject"] = "Nezha Monitoring Alert" + 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: true, + ServerName: host, + } + + auth := smtp.PlainAuth("", Conf.SMTPUser, Conf.SMTPPassword, host) + + if port == "465" { + conn, err := tls.Dial("tcp", Conf.SMTPServer, 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(Conf.AdminEmail); 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) + } + if _, err = w.Write([]byte(msg.String())); err != nil { + return fmt.Errorf("SMTP Write error: %w", err) + } + if err = w.Close(); err != nil { + return fmt.Errorf("SMTP Close error: %w", err) + } + return nil + } + + conn, err := net.Dial("tcp", Conf.SMTPServer) + 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(Conf.AdminEmail); 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) + } + if _, err = w.Write([]byte(msg.String())); err != nil { + return fmt.Errorf("SMTP Write error: %w", err) + } + if err = w.Close(); err != nil { + return fmt.Errorf("SMTP Close error: %w", err) + } + return nil +} + type _NotificationMuteLabel struct{} var NotificationMuteLabel _NotificationMuteLabel diff --git a/service/singleton/telegram_bot.go b/service/singleton/telegram_bot.go index adb950d..170299a 100644 --- a/service/singleton/telegram_bot.go +++ b/service/singleton/telegram_bot.go @@ -3,6 +3,7 @@ package singleton import ( "encoding/json" "fmt" + "io" "log" "net/http" "net/url" @@ -15,43 +16,71 @@ import ( ) 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"` + UpdateID int `json:"update_id"` + Message *tgMessage `json:"message"` + CallbackQuery *tgCallbackQuery `json:"callback_query"` +} + +type tgMessage 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"` +} + +type tgCallbackQuery struct { + ID string `json:"id"` + From struct { + ID int64 `json:"id"` + } `json:"from"` + Message *tgMessage `json:"message"` + Data string `json:"data"` } func InitTelegramBot() { + log.Printf("NEZHA>> InitTelegramBot called. Token length: %d", len(Conf.TelegramBotToken)) if Conf.TelegramBotToken == "" { log.Println("NEZHA>> TG Bot Token 未配置,跳过启动互动机器人") return } log.Println("NEZHA>> 正在启动 Telegram 互动机器人...") + + // 在启动前删除可能存在的 Webhook,防止 getUpdates 冲突 + deleteWebhookURL := fmt.Sprintf("https://api.telegram.org/bot%s/deleteWebhook?drop_pending_updates=true", Conf.TelegramBotToken) + if req, err := http.NewRequest(http.MethodPost, deleteWebhookURL, nil); err == nil { + if resp, err := utils.HttpClient.Do(req); err == nil { + log.Printf("NEZHA>> 尝试删除 Webhook 完毕,状态码: %d", resp.StatusCode) + resp.Body.Close() + } else { + log.Printf("NEZHA>> 删除 Webhook 失败: %v", err) + } + } + go func() { offset := 0 for { updates, err := getTGUpdates(Conf.TelegramBotToken, offset) if err != nil { + log.Printf("NEZHA>> 获取 TG Bot 更新失败: %v", err) // 避免过于频繁报错 - time.Sleep(30 * time.Second) + time.Sleep(10 * time.Second) continue } + if len(updates) > 0 { + log.Printf("NEZHA>> 收到了 %d 条 TG Bot 更新", len(updates)) + } + for _, update := range updates { offset = update.UpdateID + 1 - if update.Message != nil { - handleTGUpdate(update) - } + handleTGUpdate(update) } - time.Sleep(3 * time.Second) + time.Sleep(2 * time.Second) } }() } @@ -69,35 +98,60 @@ func getTGUpdates(token string, offset int) ([]tgUpdate, error) { defer resp.Body.Close() var result struct { - OK bool `json:"ok"` - Result []tgUpdate `json:"result"` + OK bool `json:"ok"` + Description string `json:"description"` + Result []tgUpdate `json:"result"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err + return nil, fmt.Errorf("解析 JSON 失败: %v", err) + } + if !result.OK { + return nil, fmt.Errorf("Telegram API error: %s", result.Description) } return result.Result, nil } func handleTGUpdate(update tgUpdate) { - if update.Message == nil || update.Message.Chat == nil { + var chatID int64 + var text string + var messageID int + + if update.Message != nil { + chatID = update.Message.Chat.ID + text = update.Message.Text + messageID = update.Message.MessageID + } else if update.CallbackQuery != nil { + chatID = update.CallbackQuery.Message.Chat.ID + text = update.CallbackQuery.Data + messageID = update.CallbackQuery.Message.MessageID + answerTGCallbackQuery(update.CallbackQuery.ID) + } else { return } - chatID := update.Message.Chat.ID adminChatID, _ := strconv.ParseInt(Conf.TelegramAdminChatID, 10, 64) // 权限检查 if adminChatID != 0 && chatID != adminChatID { + log.Printf("NEZHA>> [TG Bot] 拒绝了来自 ChatID %d 的请求", chatID) sendTGMessage(chatID, "🚫 您没有权限操作此机器人。") return } - text := update.Message.Text + log.Printf("NEZHA>> [TG Bot] 处理命令: %s", text) switch { case text == "/start" || text == "/help": sendTGMainMenu(chatID) case text == "/status" || text == "📊 运行状态": - sendTGStatus(chatID) + sendTGServerList(chatID, 0, 0) + case strings.HasPrefix(text, "sl:"): + // sl:page + page, _ := strconv.Atoi(strings.TrimPrefix(text, "sl:")) + sendTGServerList(chatID, page, messageID) + case strings.HasPrefix(text, "sd:"): + // sd:serverID + sid, _ := strconv.ParseUint(strings.TrimPrefix(text, "sd:"), 10, 64) + sendTGServerDetail(chatID, sid, messageID) case text == "/domains" || text == "🌐 域名监控": sendTGDomains(chatID) default: @@ -107,6 +161,147 @@ func handleTGUpdate(update tgUpdate) { } } +func answerTGCallbackQuery(callbackQueryID string) { + sendTGRequest("answerCallbackQuery", url.Values{ + "callback_query_id": {callbackQueryID}, + }) +} + +func sendTGServerList(chatID int64, page int, messageID int) { + allServers := ServerShared.GetSortedList() + + pageSize := 10 + totalPages := (len(allServers) + pageSize - 1) / pageSize + if page < 0 { + page = 0 + } + if page >= totalPages && totalPages > 0 { + page = totalPages - 1 + } + + start := page * pageSize + end := start + pageSize + if end > len(allServers) { + end = len(allServers) + } + + var sb strings.Builder + sb.WriteString("📊 服务器列表") + if totalPages > 1 { + sb.WriteString(fmt.Sprintf(" (第 %d/%d 页)", page+1, totalPages)) + } + sb.WriteString("\n\n请选择服务器查看详情:") + + var keyboard [][]map[string]string + for i := start; i < end; i++ { + s := allServers[i] + statusIcon := "🟢" + if !s.LastActive.After(time.Now().Add(-time.Second * 30)) { + statusIcon = "🔴" + } + keyboard = append(keyboard, []map[string]string{ + { + "text": fmt.Sprintf("%s %s", statusIcon, s.Name), + "callback_data": fmt.Sprintf("sd:%d", s.ID), + }, + }) + } + + // 导航按钮 + var navRow []map[string]string + if page > 0 { + navRow = append(navRow, map[string]string{"text": "⬅️ 上一页", "callback_data": fmt.Sprintf("sl:%d", page-1)}) + } + if totalPages > 1 { + navRow = append(navRow, map[string]string{"text": fmt.Sprintf("%d/%d", page+1, totalPages), "callback_data": "none"}) + } + if page < totalPages-1 { + navRow = append(navRow, map[string]string{"text": "下一页 ➡️", "callback_data": fmt.Sprintf("sl:%d", page+1)}) + } + if len(navRow) > 0 { + keyboard = append(keyboard, navRow) + } + + kbJSON, _ := json.Marshal(map[string]interface{}{"inline_keyboard": keyboard}) + + method := "sendMessage" + params := url.Values{ + "chat_id": {strconv.FormatInt(chatID, 10)}, + "text": {sb.String()}, + "parse_mode": {"HTML"}, + "reply_markup": {string(kbJSON)}, + } + if messageID != 0 { + method = "editMessageText" + params.Add("message_id", strconv.Itoa(messageID)) + } + sendTGRequest(method, params) +} + +func sendTGServerDetail(chatID int64, serverID uint64, messageID int) { + s, ok := ServerShared.Get(serverID) + if !ok { + sendTGMessage(chatID, "❌ 找不到该服务器。") + return + } + + statusIcon := "🟢 在线" + if !s.LastActive.After(time.Now().Add(-time.Second * 30)) { + statusIcon = "🔴 离线" + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🖥 服务器详情: %s\n", s.Name)) + sb.WriteString(fmt.Sprintf("━━━━━━━━━━━━━━━\n")) + sb.WriteString(fmt.Sprintf("状态: %s\n", statusIcon)) + sb.WriteString(fmt.Sprintf("系统: %s-%s (%s)\n", s.Host.Platform, s.Host.PlatformVersion, s.Host.Arch)) + + // 计费信息 + var noteData struct { + BillingDataMod struct { + EndDate string `json:"endDate"` + Amount string `json:"amount"` + Cycle string `json:"cycle"` + } `json:"billingDataMod"` + } + if (s.Note != "" && json.Unmarshal([]byte(s.Note), ¬eData) == nil && noteData.BillingDataMod.EndDate != "") || + (s.PublicNote != "" && json.Unmarshal([]byte(s.PublicNote), ¬eData) == nil && noteData.BillingDataMod.EndDate != "") { + if endDate, err := time.Parse(time.RFC3339, noteData.BillingDataMod.EndDate); err == nil { + daysLeft := int(endDate.Sub(time.Now()).Hours() / 24) + sb.WriteString(fmt.Sprintf("到期: %s (%d天后)\n", endDate.Format("2006-01-02"), daysLeft)) + if noteData.BillingDataMod.Amount != "" { + sb.WriteString(fmt.Sprintf("续费: %s / %s\n", noteData.BillingDataMod.Amount, noteData.BillingDataMod.Cycle)) + } + } + } + + sb.WriteString(fmt.Sprintf("━━━━━━━━━━━━━━━\n")) + sb.WriteString(fmt.Sprintf("CPU: %.1f%% (%d 核)\n", s.State.CPU, len(s.Host.CPU))) + sb.WriteString(fmt.Sprintf("内存: %.1f%% (%s / %s)\n", float64(s.State.MemUsed)/float64(s.Host.MemTotal)*100, utils.Bytes(s.State.MemUsed), utils.Bytes(s.Host.MemTotal))) + if s.Host.SwapTotal > 0 { + sb.WriteString(fmt.Sprintf("交换: %.1f%% (%s / %s)\n", float64(s.State.SwapUsed)/float64(s.Host.SwapTotal)*100, utils.Bytes(s.State.SwapUsed), utils.Bytes(s.Host.SwapTotal))) + } + sb.WriteString(fmt.Sprintf("硬盘: %.1f%% (%s / %s)\n", float64(s.State.DiskUsed)/float64(s.Host.DiskTotal)*100, utils.Bytes(s.State.DiskUsed), utils.Bytes(s.Host.DiskTotal))) + sb.WriteString(fmt.Sprintf("━━━━━━━━━━━━━━━\n")) + sb.WriteString(fmt.Sprintf("负载: %.2f / %.2f / %.2f\n", s.State.Load1, s.State.Load5, s.State.Load15)) + sb.WriteString(fmt.Sprintf("流量: ↓%s ↑%s\n", utils.Bytes(s.State.NetInTransfer), utils.Bytes(s.State.NetOutTransfer))) + sb.WriteString(fmt.Sprintf("网速: ↓%s/s ↑%s/s\n", utils.Bytes(s.State.NetInSpeed), utils.Bytes(s.State.NetOutSpeed))) + sb.WriteString(fmt.Sprintf("活跃: %s\n", s.LastActive.In(Loc).Format("2006-01-02 15:04:05"))) + + keyboard := [][]map[string]string{ + {{"text": "🔙 返回列表", "callback_data": "sl:0"}}, + } + kbJSON, _ := json.Marshal(map[string]interface{}{"inline_keyboard": keyboard}) + + sendTGRequest("editMessageText", url.Values{ + "chat_id": {strconv.FormatInt(chatID, 10)}, + "message_id": {strconv.Itoa(messageID)}, + "text": {sb.String()}, + "parse_mode": {"HTML"}, + "reply_markup": {string(kbJSON)}, + }) +} + func sendTGMainMenu(chatID int64) { menu := "👋 您好!我是哪吒监控助手。\n\n请选择以下操作:" keyboard := map[string]interface{}{ @@ -123,27 +318,7 @@ func sendTGMainMenu(chatID int64) { }) } -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") @@ -187,6 +362,7 @@ func sendTGDomains(chatID int64) { } func sendTGMessage(chatID int64, text string) { + log.Printf("NEZHA>> [TG Bot] 准备发送消息到 ChatID %d,长度: %d", chatID, len(text)) sendTGRequest("sendMessage", url.Values{ "chat_id": {strconv.FormatInt(chatID, 10)}, "text": {text}, @@ -198,11 +374,20 @@ 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 { + log.Printf("NEZHA>> [TG Bot] 创建 HTTP 请求失败: %v", err) return } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := utils.HttpClient.Do(req) if err == nil { - resp.Body.Close() + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("NEZHA>> [TG Bot] 请求 %s 失败,状态码: %d, 返回内容: %s", method, resp.StatusCode, string(body)) + } else { + log.Printf("NEZHA>> [TG Bot] 请求 %s 成功", method) + } + } else { + log.Printf("NEZHA>> [TG Bot] 发送请求 %s 失败: %v", method, err) } }