report.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. package discord
  2. import (
  3. "context"
  4. "fmt"
  5. "net"
  6. "os"
  7. "strconv"
  8. "strings"
  9. "time"
  10. "github.com/mhsanaei/3x-ui/v3/internal/config"
  11. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  12. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  13. "github.com/mhsanaei/3x-ui/v3/internal/util/common"
  14. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  15. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  16. )
  17. // ServerProvider abstracts server status and database backup operations.
  18. type ServerProvider interface {
  19. GetStatus(lastStatus *service.Status) *service.Status
  20. GetDb() ([]byte, error)
  21. BackupFilename(requestHost string) string
  22. }
  23. // InboundProvider abstracts inbound management operations.
  24. type InboundProvider interface {
  25. GetAllInbounds() ([]*model.Inbound, error)
  26. }
  27. // BuildReport constructs the status report payload and backup attachments.
  28. func (s *DiscordService) BuildReport(ctx context.Context, server ServerProvider, inbound InboundProvider) (MessagePayload, []FileAttachment, error) {
  29. hostname, _ := os.Hostname()
  30. if hostname == "" {
  31. hostname = "3x-ui"
  32. }
  33. var status *service.Status
  34. if server != nil {
  35. status = server.GetStatus(nil)
  36. }
  37. if status == nil {
  38. status = &service.Status{
  39. Loads: []float64{0, 0, 0},
  40. }
  41. status.Xray.State = service.ProcessState("unknown")
  42. status.Xray.Version = "unknown"
  43. }
  44. var onlines []string
  45. if process := service.XrayProcess(); process != nil && process.IsRunning() {
  46. onlines = process.GetOnlineClients()
  47. }
  48. trDiff := int64(0)
  49. exDiff := int64(0)
  50. now := time.Now().Unix() * 1000
  51. trafficThreshold, err := s.settingService.GetTrafficDiff()
  52. if err == nil && trafficThreshold > 0 {
  53. trDiff = int64(trafficThreshold) * 1073741824
  54. }
  55. expireThreshold, err := s.settingService.GetExpireDiff()
  56. if err == nil && expireThreshold > 0 {
  57. exDiff = int64(expireThreshold) * 86400000
  58. }
  59. var totalInbounds, disabledInbounds, exhaustedInbounds int
  60. var totalClients, disabledClients, exhaustedClients int
  61. seenClients := make(map[string]bool)
  62. if inbound != nil {
  63. inbounds, err := inbound.GetAllInbounds()
  64. if err != nil {
  65. logger.Warning("Discord report: unable to load inbounds: ", err)
  66. } else {
  67. totalInbounds = len(inbounds)
  68. for _, in := range inbounds {
  69. if !in.Enable {
  70. disabledInbounds++
  71. } else if (in.ExpiryTime > 0 && (in.ExpiryTime-now < exDiff)) ||
  72. (in.Total > 0 && (in.Total-(in.Up+in.Down) < trDiff)) {
  73. exhaustedInbounds++
  74. }
  75. for _, client := range in.ClientStats {
  76. if seenClients[client.Email] {
  77. continue
  78. }
  79. seenClients[client.Email] = true
  80. totalClients++
  81. if !client.Enable {
  82. disabledClients++
  83. } else if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) ||
  84. (client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) {
  85. exhaustedClients++
  86. }
  87. }
  88. }
  89. }
  90. }
  91. tr := translator(s.settingService)
  92. days := status.Uptime / 86400
  93. hours := (status.Uptime % 86400) / 3600
  94. uptimeStr := tr("discord.values.uptime", "Days=="+fmt.Sprint(days), "Hours=="+fmt.Sprint(hours))
  95. ramStr := fmt.Sprintf("%s / %s", common.FormatTraffic(int64(status.Mem.Current)), common.FormatTraffic(int64(status.Mem.Total)))
  96. trafficStr := tr("discord.values.traffic",
  97. "Up=="+common.FormatTraffic(int64(status.NetTraffic.Sent)),
  98. "Down=="+common.FormatTraffic(int64(status.NetTraffic.Recv)),
  99. "Total=="+common.FormatTraffic(int64(status.NetTraffic.Sent+status.NetTraffic.Recv)),
  100. )
  101. load1, load2, load3 := 0.0, 0.0, 0.0
  102. if len(status.Loads) > 0 {
  103. load1 = status.Loads[0]
  104. }
  105. if len(status.Loads) > 1 {
  106. load2 = status.Loads[1]
  107. }
  108. if len(status.Loads) > 2 {
  109. load3 = status.Loads[2]
  110. }
  111. loadStr := fmt.Sprintf("%.2f, %.2f, %.2f", load1, load2, load3)
  112. fields := []EmbedField{
  113. {Name: tr("host"), Value: hostname, Inline: true},
  114. {Name: tr("discord.fields.panelVersion"), Value: config.GetPanelVersion(), Inline: true},
  115. {Name: tr("discord.fields.xrayCore"), Value: fmt.Sprintf("%s (%s)", status.Xray.Version, status.Xray.State), Inline: true},
  116. {Name: tr("pages.index.uptime"), Value: uptimeStr, Inline: true},
  117. {Name: tr("discord.fields.systemLoad"), Value: loadStr, Inline: true},
  118. {Name: tr("pages.index.memory"), Value: ramStr, Inline: true},
  119. {Name: tr("discord.fields.networkTraffic"), Value: trafficStr, Inline: false},
  120. {Name: tr("pages.index.historyTabConnections"), Value: fmt.Sprintf("TCP: %d | UDP: %d", status.TcpCount, status.UdpCount), Inline: true},
  121. {Name: tr("pages.index.historyTitleOnline"), Value: strconv.Itoa(len(onlines)), Inline: true},
  122. {Name: tr("tgbot.inbounds"), Value: tr("discord.values.counts", "Total=="+strconv.Itoa(totalInbounds), "Depleting=="+strconv.Itoa(exhaustedInbounds), "Disabled=="+strconv.Itoa(disabledInbounds)), Inline: false},
  123. {Name: tr("clients"), Value: tr("discord.values.counts", "Total=="+strconv.Itoa(totalClients), "Depleting=="+strconv.Itoa(exhaustedClients), "Disabled=="+strconv.Itoa(disabledClients)), Inline: false},
  124. }
  125. ipv4, ipv6 := getInterfaceIPs()
  126. if ipv4 != "" {
  127. fields = append(fields, EmbedField{Name: "IPv4", Value: ipv4, Inline: true})
  128. }
  129. if ipv6 != "" {
  130. fields = append(fields, EmbedField{Name: "IPv6", Value: ipv6, Inline: true})
  131. }
  132. runTime, _ := s.settingService.GetDiscordRunTime()
  133. if runTime == "" {
  134. runTime = "@daily"
  135. }
  136. embed := Embed{
  137. Title: tr("discord.report.title"),
  138. Description: tr("discord.report.summary", "Host=="+hostname),
  139. Color: ColorBlue,
  140. Timestamp: time.Now().UTC().Format(time.RFC3339),
  141. Fields: fields,
  142. Footer: &EmbedFooter{
  143. Text: tr("discord.report.footer", "RunTime=="+runTime),
  144. },
  145. }
  146. payload := MessagePayload{
  147. Embeds: []Embed{embed},
  148. }
  149. var files []FileAttachment
  150. backupEnabled, err := s.settingService.GetDiscordBotBackup()
  151. if err == nil && backupEnabled && server != nil {
  152. dbData, err := server.GetDb()
  153. if err != nil {
  154. logger.Warning("Discord report: failed to get DB backup: ", err)
  155. } else if len(dbData) > 0 {
  156. filename := server.BackupFilename("")
  157. if filename == "" {
  158. filename = "x-ui.db"
  159. }
  160. files = append(files, FileAttachment{
  161. Filename: filename,
  162. Data: dbData,
  163. })
  164. }
  165. configPath := xray.GetConfigPath()
  166. if configData, err := os.ReadFile(configPath); err == nil && len(configData) > 0 {
  167. files = append(files, FileAttachment{
  168. Filename: "config.json",
  169. Data: configData,
  170. })
  171. }
  172. }
  173. return payload, files, nil
  174. }
  175. // SendReport generates and sends the periodic report to Discord.
  176. func (s *DiscordService) SendReport(ctx context.Context, server ServerProvider, inbound InboundProvider) error {
  177. payload, files, err := s.BuildReport(ctx, server, inbound)
  178. if err != nil {
  179. return fmt.Errorf("build discord report: %w", err)
  180. }
  181. // Separate messages: a backup over Discord's upload cap must not drop the report with it.
  182. if err := s.SendMessage(ctx, payload); err != nil {
  183. return err
  184. }
  185. if len(files) == 0 {
  186. return nil
  187. }
  188. return s.SendMessageWithFiles(ctx, MessagePayload{}, files...)
  189. }
  190. func getInterfaceIPs() (ipv4, ipv6 string) {
  191. netInterfaces, err := net.Interfaces()
  192. if err != nil {
  193. return "", ""
  194. }
  195. var v4s, v6s []string
  196. for _, iface := range netInterfaces {
  197. if (iface.Flags&net.FlagUp) == 0 || (iface.Flags&net.FlagLoopback) != 0 {
  198. continue
  199. }
  200. addrs, err := iface.Addrs()
  201. if err != nil {
  202. continue
  203. }
  204. for _, addr := range addrs {
  205. ipnet, ok := addr.(*net.IPNet)
  206. if !ok || ipnet.IP.IsLoopback() {
  207. continue
  208. }
  209. if ip := ipnet.IP.To4(); ip != nil {
  210. v4s = append(v4s, ip.String())
  211. } else if ip := ipnet.IP.To16(); ip != nil && !ipnet.IP.IsLinkLocalUnicast() {
  212. v6s = append(v6s, ip.String())
  213. }
  214. }
  215. }
  216. return strings.Join(v4s, ", "), strings.Join(v6s, ", ")
  217. }