Files
3x-ui/internal/web/service/tgbot/tgbot_event.go
T
Sentiago 891d3a8759 feat(memory): add memory threshold alerts (#5366)
* feat(memory): add memory threshold alerts

Add memory (RAM) threshold alerts following the same architecture as
CPU alerts: CheckMemJob with @every 1m cadence, memoryAlarmWanted gate,
tgMemory/smtpMemory per-subscriber settings (default 80%), EventBusCheckboxes
with inline threshold input, i18n for en-US/ru-RU with English defaults.

# Conflicts:
#	internal/web/translation/ar-EG.json
#	internal/web/translation/es-ES.json
#	internal/web/translation/fa-IR.json
#	internal/web/translation/id-ID.json
#	internal/web/translation/ja-JP.json
#	internal/web/translation/pt-BR.json
#	internal/web/translation/ru-RU.json
#	internal/web/translation/tr-TR.json
#	internal/web/translation/uk-UA.json
#	internal/web/translation/vi-VN.json
#	internal/web/translation/zh-CN.json
#	internal/web/translation/zh-TW.json

* fix: address code review findings for memory alerts

- Remove dead settingService field from CheckMemJob
- Fix cpuThreshold double-emoji in 12 locale files (code prepends 🔴)
- Align TgCpu/TgMemory fields in entity.go
- Add missing SetTgMemory function

* fix: restore settingService in CheckMemJob for consistency with CheckCpuJob
2026-06-21 17:45:33 +02:00

163 lines
4.7 KiB
Go

package tgbot
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
)
var cachedHostname string
func getHostname() string {
if cachedHostname != "" {
return cachedHostname
}
h, err := os.Hostname()
if err != nil {
cachedHostname = "unknown"
} else {
cachedHostname = h
}
return cachedHostname
}
var tgEventLimiter = eventbus.NewRateLimiter(1 * time.Minute)
// HandleEvent is the eventbus subscriber callback. It formats incoming events
// as Telegram messages and sends them to all admin chats.
func (t *Tgbot) HandleEvent(e eventbus.Event) {
if !t.isEventEnabled(e.Type) {
return
}
if e.Type != eventbus.EventLoginAttempt {
if !tgEventLimiter.Allow(e.Type, e.Source) {
return
}
}
msg := t.formatEventMessage(e)
if msg != "" {
t.SendMsgToTgbotAdmins(msg)
}
}
func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool {
events, err := t.settingService.GetTgEnabledEvents()
if err != nil || events == "" {
return false
}
for e := range strings.SplitSeq(events, ",") {
if strings.TrimSpace(e) == string(eventType) {
return true
}
}
return false
}
func (t *Tgbot) formatEventMessage(e eventbus.Event) string {
host := getHostname()
header := fmt.Sprintf("<b>📡 %s</b>\n", host)
switch e.Type {
case eventbus.EventOutboundDown:
msg := header + t.I18nBot("tgbot.messages.eventOutboundDown",
"Tag=="+e.Source)
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok {
if data.Error != "" {
msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail",
"Error=="+data.Error)
}
if data.Delay > 0 {
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
"Delay=="+fmt.Sprintf("%d", data.Delay))
}
}
return msg
case eventbus.EventOutboundUp:
msg := header + t.I18nBot("tgbot.messages.eventOutboundUp",
"Tag=="+e.Source)
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 {
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
"Delay=="+fmt.Sprintf("%d", data.Delay))
}
return msg
case eventbus.EventXrayCrash:
errStr := ""
if e.Data != nil {
errStr = fmt.Sprint(e.Data)
}
msg := header + "🔥 " + t.I18nBot("tgbot.messages.eventXrayCrash")
if errStr != "" {
msg += "\n" + t.I18nBot("tgbot.messages.eventXrayCrashError", "Error=="+errStr)
}
return msg
case eventbus.EventNodeDown:
msg := header + "🔴 " + t.I18nBot("tgbot.messages.eventNodeDown", "Name=="+e.Source)
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" {
msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", "Error=="+data.XrayError)
}
return msg
case eventbus.EventNodeUp:
msg := header + "🟢 " + t.I18nBot("tgbot.messages.eventNodeUp", "Name=="+e.Source)
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 {
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", "Delay=="+fmt.Sprintf("%d", data.LatencyMs))
}
return msg
case eventbus.EventCPUHigh:
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
tgCpu, err := t.settingService.GetTgCpu()
if err != nil || tgCpu <= 0 || data.Percent <= float64(tgCpu) {
return ""
}
return header + "🔴 " + t.I18nBot("tgbot.messages.cpuThreshold",
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
"Threshold=="+strconv.Itoa(tgCpu))
}
return ""
case eventbus.EventMemoryHigh:
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
tgMemory, err := t.settingService.GetTgMemory()
if err != nil || tgMemory <= 0 || data.Percent <= float64(tgMemory) {
return ""
}
return header + "🔴 " + t.I18nBot("tgbot.messages.memoryThreshold",
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
"Threshold=="+strconv.Itoa(tgMemory))
}
return ""
case eventbus.EventLoginAttempt:
if data, ok := e.Data.(*eventbus.LoginEventData); ok {
if data.Status == "success" {
msg := t.I18nBot("tgbot.messages.loginSuccess")
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
return msg
}
msg := t.I18nBot("tgbot.messages.loginFailed")
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
if data.Reason != "" {
msg += t.I18nBot("tgbot.messages.reason", "Reason=="+data.Reason)
}
msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
return msg
}
return header + t.I18nBot("tgbot.messages.eventLoginFallback", "Source=="+e.Source)
}
return ""
}