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