subscriber.go 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. package discord
  2. import (
  3. "context"
  4. "fmt"
  5. "os"
  6. "strings"
  7. "time"
  8. "github.com/mhsanaei/3x-ui/v3/internal/eventbus"
  9. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  10. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  11. )
  12. // Subscriber handles event bus messages and forwards them to Discord.
  13. type Subscriber struct {
  14. settingService service.SettingService
  15. discordService *DiscordService
  16. limiter *eventbus.RateLimiter
  17. }
  18. // NewSubscriber creates a new Discord event subscriber.
  19. func NewSubscriber(settingService service.SettingService, discordService *DiscordService) *Subscriber {
  20. return &Subscriber{
  21. settingService: settingService,
  22. discordService: discordService,
  23. limiter: eventbus.NewRateLimiter(1 * time.Minute),
  24. }
  25. }
  26. // HandleEvent is the eventbus subscriber callback.
  27. func (s *Subscriber) HandleEvent(e eventbus.Event) {
  28. if s.discordService == nil {
  29. return
  30. }
  31. if on, err := s.settingService.GetDiscordBotEnable(); err != nil || !on {
  32. return
  33. }
  34. if !s.isEventEnabled(e.Type) {
  35. return
  36. }
  37. embed, ok := s.FormatEmbed(e)
  38. if !ok {
  39. return
  40. }
  41. if e.Type != eventbus.EventLoginAttempt {
  42. if !s.limiter.Allow(e.Type, e.Source) {
  43. return
  44. }
  45. }
  46. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  47. defer cancel()
  48. if err := s.discordService.SendEmbed(ctx, embed); err != nil {
  49. logger.Warning("discord subscriber: send failed:", err)
  50. }
  51. }
  52. func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool {
  53. events, err := s.settingService.GetDiscordEnabledEvents()
  54. if err != nil || events == "" {
  55. return false
  56. }
  57. for e := range strings.SplitSeq(events, ",") {
  58. if strings.TrimSpace(e) == string(t) {
  59. return true
  60. }
  61. }
  62. return false
  63. }
  64. func truncateRunes(s string, maxRunes int) string {
  65. r := []rune(s)
  66. if len(r) <= maxRunes {
  67. return s
  68. }
  69. if maxRunes <= 3 {
  70. return string(r[:maxRunes])
  71. }
  72. return string(r[:maxRunes-3]) + "..."
  73. }
  74. func cleanField(name, value string, inline bool) EmbedField {
  75. name = strings.TrimSpace(name)
  76. if name == "" {
  77. name = "-"
  78. } else {
  79. name = truncateRunes(name, 256)
  80. }
  81. value = strings.TrimSpace(value)
  82. if value == "" {
  83. value = "-"
  84. } else {
  85. value = truncateRunes(value, 1024)
  86. }
  87. return EmbedField{
  88. Name: name,
  89. Value: value,
  90. Inline: inline,
  91. }
  92. }
  93. // FormatEmbed converts an eventbus.Event into a Discord Embed.
  94. // Returns false if the event should not produce a message (e.g. thresholds not exceeded).
  95. func (s *Subscriber) FormatEmbed(e eventbus.Event) (Embed, bool) {
  96. h, _ := os.Hostname()
  97. if h == "" {
  98. h = "unknown"
  99. }
  100. var ts string
  101. if e.Timestamp.IsZero() {
  102. ts = time.Now().UTC().Format(time.RFC3339)
  103. } else {
  104. ts = e.Timestamp.UTC().Format(time.RFC3339)
  105. }
  106. footer := &EmbedFooter{
  107. Text: truncateRunes("3x-ui • "+h, 2048),
  108. }
  109. tr := translator(s.settingService)
  110. switch e.Type {
  111. case eventbus.EventOutboundDown:
  112. fields := []EmbedField{
  113. cleanField(tr("discord.fields.outbound"), e.Source, true),
  114. }
  115. var data *eventbus.OutboundHealthData
  116. switch d := e.Data.(type) {
  117. case *eventbus.OutboundHealthData:
  118. data = d
  119. case eventbus.OutboundHealthData:
  120. data = &d
  121. }
  122. if data != nil {
  123. if data.Error != "" {
  124. fields = append(fields, cleanField(tr("discord.fields.error"), data.Error, false))
  125. }
  126. if data.Delay > 0 {
  127. fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.Delay), true))
  128. }
  129. }
  130. return Embed{
  131. Title: tr("discord.alerts.outboundDown"),
  132. Color: ColorRed,
  133. Timestamp: ts,
  134. Fields: fields,
  135. Footer: footer,
  136. }, true
  137. case eventbus.EventOutboundUp:
  138. fields := []EmbedField{
  139. cleanField(tr("discord.fields.outbound"), e.Source, true),
  140. }
  141. var data *eventbus.OutboundHealthData
  142. switch d := e.Data.(type) {
  143. case *eventbus.OutboundHealthData:
  144. data = d
  145. case eventbus.OutboundHealthData:
  146. data = &d
  147. }
  148. if data != nil && data.Delay > 0 {
  149. fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.Delay), true))
  150. }
  151. return Embed{
  152. Title: tr("discord.alerts.outboundUp"),
  153. Color: ColorGreen,
  154. Timestamp: ts,
  155. Fields: fields,
  156. Footer: footer,
  157. }, true
  158. case eventbus.EventNodeDown:
  159. fields := []EmbedField{
  160. cleanField(tr("discord.fields.node"), e.Source, true),
  161. }
  162. var data *eventbus.NodeHealthData
  163. switch d := e.Data.(type) {
  164. case *eventbus.NodeHealthData:
  165. data = d
  166. case eventbus.NodeHealthData:
  167. data = &d
  168. }
  169. if data != nil && data.XrayError != "" {
  170. fields = append(fields, cleanField(tr("discord.fields.error"), data.XrayError, false))
  171. }
  172. return Embed{
  173. Title: tr("discord.alerts.nodeDown"),
  174. Color: ColorRed,
  175. Timestamp: ts,
  176. Fields: fields,
  177. Footer: footer,
  178. }, true
  179. case eventbus.EventNodeUp:
  180. fields := []EmbedField{
  181. cleanField(tr("discord.fields.node"), e.Source, true),
  182. }
  183. var data *eventbus.NodeHealthData
  184. switch d := e.Data.(type) {
  185. case *eventbus.NodeHealthData:
  186. data = d
  187. case eventbus.NodeHealthData:
  188. data = &d
  189. }
  190. if data != nil && data.LatencyMs > 0 {
  191. fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.LatencyMs), true))
  192. }
  193. return Embed{
  194. Title: tr("discord.alerts.nodeUp"),
  195. Color: ColorGreen,
  196. Timestamp: ts,
  197. Fields: fields,
  198. Footer: footer,
  199. }, true
  200. case eventbus.EventXrayCrash:
  201. var fields []EmbedField
  202. if e.Data != nil {
  203. fields = append(fields, cleanField(tr("discord.fields.error"), fmt.Sprint(e.Data), false))
  204. }
  205. return Embed{
  206. Title: tr("discord.alerts.xrayCrash"),
  207. Color: ColorRed,
  208. Timestamp: ts,
  209. Fields: fields,
  210. Footer: footer,
  211. }, true
  212. case eventbus.EventCPUHigh:
  213. var data *eventbus.SystemMetricData
  214. switch d := e.Data.(type) {
  215. case *eventbus.SystemMetricData:
  216. data = d
  217. case eventbus.SystemMetricData:
  218. data = &d
  219. }
  220. if data != nil {
  221. discordCpu, err := s.settingService.GetDiscordCpu()
  222. if err != nil || discordCpu <= 0 || data.Percent <= float64(discordCpu) {
  223. return Embed{}, false
  224. }
  225. fields := []EmbedField{
  226. cleanField(tr("usage"), fmt.Sprintf("%.2f%%", data.Percent), true),
  227. cleanField(tr("discord.fields.threshold"), fmt.Sprintf("%d%%", discordCpu), true),
  228. }
  229. return Embed{
  230. Title: tr("discord.alerts.cpuHigh"),
  231. Color: ColorOrange,
  232. Timestamp: ts,
  233. Fields: fields,
  234. Footer: footer,
  235. }, true
  236. }
  237. return Embed{}, false
  238. case eventbus.EventMemoryHigh:
  239. var data *eventbus.SystemMetricData
  240. switch d := e.Data.(type) {
  241. case *eventbus.SystemMetricData:
  242. data = d
  243. case eventbus.SystemMetricData:
  244. data = &d
  245. }
  246. if data != nil {
  247. discordMem, err := s.settingService.GetDiscordMemory()
  248. if err != nil || discordMem <= 0 || data.Percent <= float64(discordMem) {
  249. return Embed{}, false
  250. }
  251. fields := []EmbedField{
  252. cleanField(tr("usage"), fmt.Sprintf("%.2f%%", data.Percent), true),
  253. cleanField(tr("discord.fields.threshold"), fmt.Sprintf("%d%%", discordMem), true),
  254. }
  255. return Embed{
  256. Title: tr("discord.alerts.memoryHigh"),
  257. Color: ColorOrange,
  258. Timestamp: ts,
  259. Fields: fields,
  260. Footer: footer,
  261. }, true
  262. }
  263. return Embed{}, false
  264. case eventbus.EventLoginAttempt:
  265. var data *eventbus.LoginEventData
  266. switch d := e.Data.(type) {
  267. case *eventbus.LoginEventData:
  268. data = d
  269. case eventbus.LoginEventData:
  270. data = &d
  271. }
  272. if data != nil {
  273. if data.Status == "success" {
  274. fields := []EmbedField{
  275. cleanField(tr("username"), data.Username, true),
  276. cleanField("IP", data.IP, true),
  277. }
  278. if data.Time != "" {
  279. fields = append(fields, cleanField(tr("discord.fields.time"), data.Time, true))
  280. }
  281. return Embed{
  282. Title: tr("discord.alerts.loginSuccess"),
  283. Color: ColorGreen,
  284. Timestamp: ts,
  285. Fields: fields,
  286. Footer: footer,
  287. }, true
  288. }
  289. fields := []EmbedField{
  290. cleanField(tr("username"), data.Username, true),
  291. cleanField("IP", data.IP, true),
  292. }
  293. if data.Reason != "" {
  294. fields = append(fields, cleanField(tr("discord.fields.reason"), data.Reason, false))
  295. }
  296. if data.Time != "" {
  297. fields = append(fields, cleanField(tr("discord.fields.time"), data.Time, true))
  298. }
  299. return Embed{
  300. Title: tr("discord.alerts.loginFailed"),
  301. Color: ColorRed,
  302. Timestamp: ts,
  303. Fields: fields,
  304. Footer: footer,
  305. }, true
  306. }
  307. fields := []EmbedField{
  308. cleanField(tr("discord.fields.source"), e.Source, true),
  309. }
  310. return Embed{
  311. Title: tr("discord.alerts.loginFailed"),
  312. Color: ColorRed,
  313. Timestamp: ts,
  314. Fields: fields,
  315. Footer: footer,
  316. }, true
  317. }
  318. return Embed{}, false
  319. }