mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-05-13 17:39:41 +00:00
feat: separate expiry notification days and refactor TG bot interactive logic
This commit is contained in:
@@ -25,3 +25,5 @@
|
|||||||
/resource/static/custom
|
/resource/static/custom
|
||||||
/cmd/dashboard/docs
|
/cmd/dashboard/docs
|
||||||
/data/*
|
/data/*
|
||||||
|
app
|
||||||
|
dashboard
|
||||||
@@ -113,6 +113,12 @@ func updateConfig(c *gin.Context) (any, error) {
|
|||||||
singleton.Conf.CustomLinks = sf.CustomLinks
|
singleton.Conf.CustomLinks = sf.CustomLinks
|
||||||
singleton.Conf.TelegramBotToken = sf.TelegramBotToken
|
singleton.Conf.TelegramBotToken = sf.TelegramBotToken
|
||||||
singleton.Conf.TelegramAdminChatID = sf.TelegramAdminChatID
|
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 {
|
if err := singleton.Conf.Save(); err != nil {
|
||||||
return nil, newGormError("%v", err)
|
return nil, newGormError("%v", err)
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ type ConfigDashboard struct {
|
|||||||
ExpiryNotificationGroupID uint64 `koanf:"expiry_notification_group_id" json:"expiry_notification_group_id,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"`
|
TelegramBotToken string `koanf:"telegram_bot_token" json:"telegram_bot_token,omitempty"`
|
||||||
TelegramAdminChatID string `koanf:"telegram_admin_chat_id" json:"telegram_admin_chat_id,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 {
|
type Config struct {
|
||||||
|
|||||||
+48
-177
@@ -1,14 +1,10 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/smtp"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -38,10 +34,15 @@ type NotificationServerBundle struct {
|
|||||||
const (
|
const (
|
||||||
_ = iota
|
_ = iota
|
||||||
NotificationTypeWebhook
|
NotificationTypeWebhook
|
||||||
NotificationTypeSMTP
|
NotificationTypeEmail
|
||||||
NotificationTypeTelegram
|
NotificationTypeTelegram
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
SendGlobalTelegramFunc func(message string) error
|
||||||
|
SendGlobalEmailFunc func(message string) error
|
||||||
|
)
|
||||||
|
|
||||||
type Notification struct {
|
type Notification struct {
|
||||||
Common
|
Common
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -49,8 +50,8 @@ type Notification struct {
|
|||||||
URL string `json:"url"` // SMTP: host:port, Webhook: url, Telegram: bot_token
|
URL string `json:"url"` // SMTP: host:port, Webhook: url, Telegram: bot_token
|
||||||
RequestMethod uint8 `json:"request_method"`
|
RequestMethod uint8 `json:"request_method"`
|
||||||
RequestType uint8 `json:"request_type"`
|
RequestType uint8 `json:"request_type"`
|
||||||
RequestHeader string `json:"request_header" gorm:"type:longtext"` // SMTP: user:pass, Webhook: header, Telegram: chat_id
|
RequestHeader string `json:"request_header" gorm:"type:longtext"` // Webhook: header
|
||||||
RequestBody string `json:"request_body" gorm:"type:longtext"` // SMTP: recipient, Webhook: body, Telegram: (ignored)
|
RequestBody string `json:"request_body" gorm:"type:longtext"` // Webhook: body
|
||||||
VerifyTLS *bool `json:"verify_tls,omitempty"`
|
VerifyTLS *bool `json:"verify_tls,omitempty"`
|
||||||
FormatMetricUnits *bool `json:"format_metric_units,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 {
|
func (ns *NotificationServerBundle) Send(message string) error {
|
||||||
n := ns.Notification
|
n := ns.Notification
|
||||||
if n.Type == NotificationTypeSMTP {
|
|
||||||
return ns.sendSMTP(message)
|
if n.Type == NotificationTypeEmail || n.Type == NotificationTypeTelegram {
|
||||||
|
template := n.RequestBody
|
||||||
|
if template == "" {
|
||||||
|
template = message
|
||||||
}
|
}
|
||||||
if n.Type == NotificationTypeTelegram {
|
content := ns.replaceParamsInString(template, message, nil)
|
||||||
return ns.sendTelegram(message)
|
|
||||||
|
if n.Type == NotificationTypeEmail && SendGlobalEmailFunc != nil {
|
||||||
|
return SendGlobalEmailFunc(content)
|
||||||
|
}
|
||||||
|
if n.Type == NotificationTypeTelegram && SendGlobalTelegramFunc != nil {
|
||||||
|
return SendGlobalTelegramFunc(content)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var client *http.Client
|
var client *http.Client
|
||||||
@@ -177,175 +188,9 @@ func (ns *NotificationServerBundle) Send(message string) error {
|
|||||||
return nil
|
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 替换字符串中的占位符
|
// replaceParamInString 替换字符串中的占位符
|
||||||
func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string {
|
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 {
|
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,
|
replacements = append(replacements,
|
||||||
"#SERVER.NAME#", mod(ns.Server.Name),
|
"#SERVER.NAME#", mod(ns.Server.Name),
|
||||||
"#SERVER.ID#", mod(fmt.Sprintf("%d", ns.Server.ID)),
|
"#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
|
// Converted metrics
|
||||||
"#SERVER.CPU#", mod(ns.formatUsage(false, ns.Server.State.CPU)),
|
"#SERVER.CPU#", mod(ns.formatUsage(false, ns.Server.State.CPU)),
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ type SettingForm struct {
|
|||||||
ExpiryNotificationGroupID uint64 `json:"expiry_notification_group_id,omitempty"`
|
ExpiryNotificationGroupID uint64 `json:"expiry_notification_group_id,omitempty"`
|
||||||
TelegramBotToken string `json:"telegram_bot_token,omitempty" validate:"optional"`
|
TelegramBotToken string `json:"telegram_bot_token,omitempty" validate:"optional"`
|
||||||
TelegramAdminChatID string `json:"telegram_admin_chat_id,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 {
|
type Setting struct {
|
||||||
|
|||||||
+40
-14
@@ -10,6 +10,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -340,6 +342,36 @@ func DeleteDomain(id uint64) error {
|
|||||||
return DB.Delete(&model.Domain{}, id).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 检查域名到期和自动续费的定时任务
|
// CronJobForDomainStatus 检查域名到期和自动续费的定时任务
|
||||||
func CronJobForDomainStatus() {
|
func CronJobForDomainStatus() {
|
||||||
log.Println("NEZHA>> Cron::开始执行域名状态检查任务")
|
log.Println("NEZHA>> Cron::开始执行域名状态检查任务")
|
||||||
@@ -376,18 +408,15 @@ func CronJobForDomainStatus() {
|
|||||||
daysLeft := int(endDate.Sub(now).Hours() / 24)
|
daysLeft := int(endDate.Sub(now).Hours() / 24)
|
||||||
|
|
||||||
// 只有在到期前一定天数通知,且避开重复通知 (简单逻辑:每天通知一次)
|
// 只有在到期前一定天数通知,且避开重复通知 (简单逻辑:每天通知一次)
|
||||||
if Conf.ExpiryNotificationGroupID != 0 {
|
if Conf.ExpiryNotificationGroupID != 0 && isDomainNotificationDay(daysLeft) {
|
||||||
msg := ""
|
msg := ""
|
||||||
switch daysLeft + 1 {
|
if daysLeft+1 > 0 {
|
||||||
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"))
|
} else {
|
||||||
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"))
|
||||||
}
|
}
|
||||||
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) {
|
if now.After(endDate) {
|
||||||
if billing.AutoRenewal == "1" {
|
if billing.AutoRenewal == "1" {
|
||||||
@@ -457,17 +486,14 @@ func CronJobForServerStatus() {
|
|||||||
|
|
||||||
daysLeft := int(endDate.Sub(now).Hours() / 24)
|
daysLeft := int(endDate.Sub(now).Hours() / 24)
|
||||||
|
|
||||||
if Conf.ExpiryNotificationGroupID != 0 {
|
if Conf.ExpiryNotificationGroupID != 0 && isServerNotificationDay(daysLeft) {
|
||||||
msg := ""
|
msg := ""
|
||||||
switch daysLeft + 1 {
|
if daysLeft+1 > 0 {
|
||||||
case 30, 15, 7, 3, 1:
|
|
||||||
msg = fmt.Sprintf("VPS [%s] 即将到期,剩余 %d 天。到期时间: %s", s.Name, daysLeft+1, endDate.Format("2006-01-02"))
|
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"))
|
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), &s)
|
||||||
NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-server-%d-%d", s.ID, daysLeft))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Println("NEZHA>> Cron::服务器到期检查任务执行完毕")
|
log.Println("NEZHA>> Cron::服务器到期检查任务执行完毕")
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ package singleton
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +22,35 @@ const (
|
|||||||
firstNotificationDelay = time.Minute * 15
|
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("⚠️ <b>Nezha 报警通知</b>\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 {
|
type NotificationClass struct {
|
||||||
class[uint64, *model.Notification]
|
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{}
|
type _NotificationMuteLabel struct{}
|
||||||
|
|
||||||
var NotificationMuteLabel _NotificationMuteLabel
|
var NotificationMuteLabel _NotificationMuteLabel
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package singleton
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -16,7 +17,11 @@ import (
|
|||||||
|
|
||||||
type tgUpdate struct {
|
type tgUpdate struct {
|
||||||
UpdateID int `json:"update_id"`
|
UpdateID int `json:"update_id"`
|
||||||
Message *struct {
|
Message *tgMessage `json:"message"`
|
||||||
|
CallbackQuery *tgCallbackQuery `json:"callback_query"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgMessage struct {
|
||||||
MessageID int `json:"message_id"`
|
MessageID int `json:"message_id"`
|
||||||
From *struct {
|
From *struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -25,33 +30,57 @@ type tgUpdate struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
} `json:"chat"`
|
} `json:"chat"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
} `json:"message"`
|
}
|
||||||
|
|
||||||
|
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() {
|
func InitTelegramBot() {
|
||||||
|
log.Printf("NEZHA>> InitTelegramBot called. Token length: %d", len(Conf.TelegramBotToken))
|
||||||
if Conf.TelegramBotToken == "" {
|
if Conf.TelegramBotToken == "" {
|
||||||
log.Println("NEZHA>> TG Bot Token 未配置,跳过启动互动机器人")
|
log.Println("NEZHA>> TG Bot Token 未配置,跳过启动互动机器人")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("NEZHA>> 正在启动 Telegram 互动机器人...")
|
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() {
|
go func() {
|
||||||
offset := 0
|
offset := 0
|
||||||
for {
|
for {
|
||||||
updates, err := getTGUpdates(Conf.TelegramBotToken, offset)
|
updates, err := getTGUpdates(Conf.TelegramBotToken, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("NEZHA>> 获取 TG Bot 更新失败: %v", err)
|
||||||
// 避免过于频繁报错
|
// 避免过于频繁报错
|
||||||
time.Sleep(30 * time.Second)
|
time.Sleep(10 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(updates) > 0 {
|
||||||
|
log.Printf("NEZHA>> 收到了 %d 条 TG Bot 更新", len(updates))
|
||||||
|
}
|
||||||
|
|
||||||
for _, update := range updates {
|
for _, update := range updates {
|
||||||
offset = update.UpdateID + 1
|
offset = update.UpdateID + 1
|
||||||
if update.Message != nil {
|
|
||||||
handleTGUpdate(update)
|
handleTGUpdate(update)
|
||||||
}
|
}
|
||||||
}
|
time.Sleep(2 * time.Second)
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -70,34 +99,59 @@ func getTGUpdates(token string, offset int) ([]tgUpdate, error) {
|
|||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
|
Description string `json:"description"`
|
||||||
Result []tgUpdate `json:"result"`
|
Result []tgUpdate `json:"result"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
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
|
return result.Result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleTGUpdate(update tgUpdate) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID := update.Message.Chat.ID
|
|
||||||
adminChatID, _ := strconv.ParseInt(Conf.TelegramAdminChatID, 10, 64)
|
adminChatID, _ := strconv.ParseInt(Conf.TelegramAdminChatID, 10, 64)
|
||||||
|
|
||||||
// 权限检查
|
// 权限检查
|
||||||
if adminChatID != 0 && chatID != adminChatID {
|
if adminChatID != 0 && chatID != adminChatID {
|
||||||
|
log.Printf("NEZHA>> [TG Bot] 拒绝了来自 ChatID %d 的请求", chatID)
|
||||||
sendTGMessage(chatID, "🚫 您没有权限操作此机器人。")
|
sendTGMessage(chatID, "🚫 您没有权限操作此机器人。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
text := update.Message.Text
|
log.Printf("NEZHA>> [TG Bot] 处理命令: %s", text)
|
||||||
switch {
|
switch {
|
||||||
case text == "/start" || text == "/help":
|
case text == "/start" || text == "/help":
|
||||||
sendTGMainMenu(chatID)
|
sendTGMainMenu(chatID)
|
||||||
case text == "/status" || text == "📊 运行状态":
|
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 == "🌐 域名监控":
|
case text == "/domains" || text == "🌐 域名监控":
|
||||||
sendTGDomains(chatID)
|
sendTGDomains(chatID)
|
||||||
default:
|
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("📊 <b>服务器列表</b>")
|
||||||
|
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("🖥 <b>服务器详情: %s</b>\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) {
|
func sendTGMainMenu(chatID int64) {
|
||||||
menu := "👋 您好!我是哪吒监控助手。\n\n请选择以下操作:"
|
menu := "👋 您好!我是哪吒监控助手。\n\n请选择以下操作:"
|
||||||
keyboard := map[string]interface{}{
|
keyboard := map[string]interface{}{
|
||||||
@@ -123,27 +318,7 @@ func sendTGMainMenu(chatID int64) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func sendTGDomains(chatID int64) {
|
||||||
domains, err := GetDomains("admin")
|
domains, err := GetDomains("admin")
|
||||||
@@ -187,6 +362,7 @@ func sendTGDomains(chatID int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendTGMessage(chatID int64, text string) {
|
func sendTGMessage(chatID int64, text string) {
|
||||||
|
log.Printf("NEZHA>> [TG Bot] 准备发送消息到 ChatID %d,长度: %d", chatID, len(text))
|
||||||
sendTGRequest("sendMessage", url.Values{
|
sendTGRequest("sendMessage", url.Values{
|
||||||
"chat_id": {strconv.FormatInt(chatID, 10)},
|
"chat_id": {strconv.FormatInt(chatID, 10)},
|
||||||
"text": {text},
|
"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)
|
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/%s", Conf.TelegramBotToken, method)
|
||||||
req, err := http.NewRequest(http.MethodPost, apiURL, strings.NewReader(params.Encode()))
|
req, err := http.NewRequest(http.MethodPost, apiURL, strings.NewReader(params.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("NEZHA>> [TG Bot] 创建 HTTP 请求失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
resp, err := utils.HttpClient.Do(req)
|
resp, err := utils.HttpClient.Do(req)
|
||||||
if err == nil {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user