Files
3x-ui/internal/web/service/tgbot/tgbot_event.go
T
MHSanaei d63583caaf fix(tgbot): keep a login alert's username on one line
The Telegram login alert is a list of "label: value" lines, and the
username, whatever the visitor typed on the login form, was inserted
with its line breaks intact. Text after a break read as the panel's
own IP and time lines, letting a failed login forge the alert.

Control characters and Unicode line separators in the username become
spaces, in the failed and the successful alert alike.
2026-11-01 13:37:40 +01:00

182 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tgbot
import (
"fmt"
"os"
"strconv"
"strings"
"unicode"
"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
}
// HandleEvent is the eventbus subscriber callback: one kind of event keeps ONE
// live card per admin chat, so a repeat edits it instead of a wall of messages.
func (t *Tgbot) HandleEvent(e eventbus.Event) {
if !t.isEventEnabled(e.Type) {
return
}
msg := t.formatEventMessage(e)
if msg == "" {
return
}
kind := t.eventNoticeKind(e)
for _, adminID := range adminSnapshot() {
t.liveNotice(adminID, kind, msg)
}
}
// eventNoticeKind is the live card a repeat of this event edits. A login success
// must not fold into the failure card from the same IP: outcome is part of the key.
func (t *Tgbot) eventNoticeKind(e eventbus.Event) string {
if e.Type == eventbus.EventLoginAttempt {
if data, ok := e.Data.(*eventbus.LoginEventData); ok {
return string(e.Type) + ":" + e.Source + ":" + data.Status
}
}
return string(e.Type) + ":" + e.Source
}
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
}
// oneLine keeps a value the panel did not write itself on its own line of an
// alert: control characters and Unicode line separators become spaces.
func oneLine(s string) string {
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) || r == '
' || r == '
' {
return ' '
}
return r
}, s)
}
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=="+oneLine(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=="+oneLine(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 ""
}