subscriber.go 8.9 KB

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