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)
}
}