package discord import ( "context" "fmt" "os" "strings" "time" "unicode/utf16" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) // Subscriber handles event bus messages and forwards them to Discord. type Subscriber struct { settingService service.SettingService discordService *DiscordService limiter *eventbus.RateLimiter } // NewSubscriber creates a new Discord event subscriber. func NewSubscriber(settingService service.SettingService, discordService *DiscordService) *Subscriber { return &Subscriber{ settingService: settingService, discordService: discordService, limiter: eventbus.NewRateLimiter(1 * time.Minute), } } // HandleEvent is the eventbus subscriber callback. func (s *Subscriber) HandleEvent(e eventbus.Event) { if s.discordService == nil { return } if on, err := s.settingService.GetDiscordBotEnable(); err != nil || !on { return } if !s.isEventEnabled(e.Type) { return } embed, ok := s.FormatEmbed(e) if !ok { return } if e.Type != eventbus.EventLoginAttempt { if !s.limiter.Allow(e.Type, e.Source) { return } } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := s.discordService.SendEmbed(ctx, embed); err != nil { logger.Warning("discord subscriber: send failed:", err) } } func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool { events, err := s.settingService.GetDiscordEnabledEvents() if err != nil || events == "" { return false } for e := range strings.SplitSeq(events, ",") { if strings.TrimSpace(e) == string(t) { return true } } return false } // truncateUnits cuts s to maxUnits of the length Discord measures its field // name, value and footer caps by: runes alone overrun them on astral text. func truncateUnits(s string, maxUnits int) string { if discordCharLen(s) <= maxUnits { return s } suffix := "..." budget := maxUnits if maxUnits <= len(suffix) { suffix = "" } else { budget -= len(suffix) } var b strings.Builder units := 0 for _, r := range s { size := utf16.RuneLen(r) if size < 1 { size = 1 } if units+size > budget { break } b.WriteRune(r) units += size } return b.String() + suffix } func cleanField(name, value string, inline bool) EmbedField { name = strings.TrimSpace(name) if name == "" { name = "-" } else { name = truncateUnits(name, 256) } value = strings.TrimSpace(value) if value == "" { value = "-" } else { value = truncateUnits(value, 1024) } return EmbedField{ Name: name, Value: value, Inline: inline, } } // FormatEmbed converts an eventbus.Event into a Discord Embed. // Returns false if the event should not produce a message (e.g. thresholds not exceeded). func (s *Subscriber) FormatEmbed(e eventbus.Event) (Embed, bool) { h, _ := os.Hostname() if h == "" { h = "unknown" } var ts string if e.Timestamp.IsZero() { ts = time.Now().UTC().Format(time.RFC3339) } else { ts = e.Timestamp.UTC().Format(time.RFC3339) } footer := &EmbedFooter{ Text: truncateUnits("3x-ui • "+h, 2048), } tr := translator(s.settingService) switch e.Type { case eventbus.EventOutboundDown: fields := []EmbedField{ cleanField(tr("discord.fields.outbound"), e.Source, true), } var data *eventbus.OutboundHealthData switch d := e.Data.(type) { case *eventbus.OutboundHealthData: data = d case eventbus.OutboundHealthData: data = &d } if data != nil { if data.Error != "" { fields = append(fields, cleanField(tr("discord.fields.error"), data.Error, false)) } if data.Delay > 0 { fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.Delay), true)) } } return Embed{ Title: tr("discord.alerts.outboundDown"), Color: ColorRed, Timestamp: ts, Fields: fields, Footer: footer, }, true case eventbus.EventOutboundUp: fields := []EmbedField{ cleanField(tr("discord.fields.outbound"), e.Source, true), } var data *eventbus.OutboundHealthData switch d := e.Data.(type) { case *eventbus.OutboundHealthData: data = d case eventbus.OutboundHealthData: data = &d } if data != nil && data.Delay > 0 { fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.Delay), true)) } return Embed{ Title: tr("discord.alerts.outboundUp"), Color: ColorGreen, Timestamp: ts, Fields: fields, Footer: footer, }, true case eventbus.EventNodeDown: fields := []EmbedField{ cleanField(tr("discord.fields.node"), e.Source, true), } var data *eventbus.NodeHealthData switch d := e.Data.(type) { case *eventbus.NodeHealthData: data = d case eventbus.NodeHealthData: data = &d } if data != nil && data.XrayError != "" { fields = append(fields, cleanField(tr("discord.fields.error"), data.XrayError, false)) } return Embed{ Title: tr("discord.alerts.nodeDown"), Color: ColorRed, Timestamp: ts, Fields: fields, Footer: footer, }, true case eventbus.EventNodeUp: fields := []EmbedField{ cleanField(tr("discord.fields.node"), e.Source, true), } var data *eventbus.NodeHealthData switch d := e.Data.(type) { case *eventbus.NodeHealthData: data = d case eventbus.NodeHealthData: data = &d } if data != nil && data.LatencyMs > 0 { fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.LatencyMs), true)) } return Embed{ Title: tr("discord.alerts.nodeUp"), Color: ColorGreen, Timestamp: ts, Fields: fields, Footer: footer, }, true case eventbus.EventXrayCrash: var fields []EmbedField if e.Data != nil { fields = append(fields, cleanField(tr("discord.fields.error"), fmt.Sprint(e.Data), false)) } return Embed{ Title: tr("discord.alerts.xrayCrash"), Color: ColorRed, Timestamp: ts, Fields: fields, Footer: footer, }, true case eventbus.EventCPUHigh: var data *eventbus.SystemMetricData switch d := e.Data.(type) { case *eventbus.SystemMetricData: data = d case eventbus.SystemMetricData: data = &d } if data != nil { discordCpu, err := s.settingService.GetDiscordCpu() if err != nil || discordCpu <= 0 || data.Percent <= float64(discordCpu) { return Embed{}, false } fields := []EmbedField{ cleanField(tr("usage"), fmt.Sprintf("%.2f%%", data.Percent), true), cleanField(tr("discord.fields.threshold"), fmt.Sprintf("%d%%", discordCpu), true), } return Embed{ Title: tr("discord.alerts.cpuHigh"), Color: ColorOrange, Timestamp: ts, Fields: fields, Footer: footer, }, true } return Embed{}, false case eventbus.EventMemoryHigh: var data *eventbus.SystemMetricData switch d := e.Data.(type) { case *eventbus.SystemMetricData: data = d case eventbus.SystemMetricData: data = &d } if data != nil { discordMem, err := s.settingService.GetDiscordMemory() if err != nil || discordMem <= 0 || data.Percent <= float64(discordMem) { return Embed{}, false } fields := []EmbedField{ cleanField(tr("usage"), fmt.Sprintf("%.2f%%", data.Percent), true), cleanField(tr("discord.fields.threshold"), fmt.Sprintf("%d%%", discordMem), true), } return Embed{ Title: tr("discord.alerts.memoryHigh"), Color: ColorOrange, Timestamp: ts, Fields: fields, Footer: footer, }, true } return Embed{}, false case eventbus.EventLoginAttempt: var data *eventbus.LoginEventData switch d := e.Data.(type) { case *eventbus.LoginEventData: data = d case eventbus.LoginEventData: data = &d } if data != nil { if data.Status == "success" { fields := []EmbedField{ cleanField(tr("username"), data.Username, true), cleanField("IP", data.IP, true), } if data.Time != "" { fields = append(fields, cleanField(tr("discord.fields.time"), data.Time, true)) } return Embed{ Title: tr("discord.alerts.loginSuccess"), Color: ColorGreen, Timestamp: ts, Fields: fields, Footer: footer, }, true } fields := []EmbedField{ cleanField(tr("username"), data.Username, true), cleanField("IP", data.IP, true), } if data.Reason != "" { fields = append(fields, cleanField(tr("discord.fields.reason"), data.Reason, false)) } if data.Time != "" { fields = append(fields, cleanField(tr("discord.fields.time"), data.Time, true)) } return Embed{ Title: tr("discord.alerts.loginFailed"), Color: ColorRed, Timestamp: ts, Fields: fields, Footer: footer, }, true } fields := []EmbedField{ cleanField(tr("discord.fields.source"), e.Source, true), } return Embed{ Title: tr("discord.alerts.loginFailed"), Color: ColorRed, Timestamp: ts, Fields: fields, Footer: footer, }, true } return Embed{}, false }