gateway.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754
  1. package discord
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "net/http"
  8. "net/url"
  9. "os"
  10. "strconv"
  11. "strings"
  12. "sync"
  13. "sync/atomic"
  14. "time"
  15. "github.com/gorilla/websocket"
  16. "github.com/mhsanaei/3x-ui/v3/internal/config"
  17. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  18. "github.com/mhsanaei/3x-ui/v3/internal/util/common"
  19. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  20. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  21. )
  22. const (
  23. defaultGatewayURL = "wss://gateway.discord.gg/?v=10&encoding=json"
  24. opDispatch = 0
  25. opHeartbeat = 1
  26. opIdentify = 2
  27. opHello = 10
  28. opHeartbeatACK = 11
  29. // GUILDS (1<<0) | GUILD_MESSAGES (1<<9) | DIRECT_MESSAGES (1<<12) | MESSAGE_CONTENT (1<<15)
  30. discordIntents = 37377
  31. // Discord rejects a whole message past these caps: 25 fields per embed, ten
  32. // embeds, 6000 counted characters.
  33. discordEmbedFieldLimit = 25
  34. discordEmbedsPerMsg = 10
  35. discordMessageCharLimit = 6000
  36. // How long a 429 may ask a paged reply to wait before it gives up on the
  37. // page: longer than this and the operator is staring at a dead command.
  38. discordRateLimitWait = 5 * time.Second
  39. )
  40. // GatewayPayload represents a Discord Gateway WebSocket frame.
  41. type GatewayPayload struct {
  42. Op int `json:"op"`
  43. D json.RawMessage `json:"d,omitempty"`
  44. S *int64 `json:"s,omitempty"`
  45. T string `json:"t,omitempty"`
  46. }
  47. // HelloData represents the payload received in Opcode 10 Hello.
  48. type HelloData struct {
  49. HeartbeatInterval int `json:"heartbeat_interval"`
  50. }
  51. // IdentifyData represents the payload sent in Opcode 2 Identify.
  52. type IdentifyData struct {
  53. Token string `json:"token"`
  54. Intents int `json:"intents"`
  55. Properties IdentifyProperties `json:"properties"`
  56. }
  57. // IdentifyProperties metadata for Discord identification.
  58. type IdentifyProperties struct {
  59. OS string `json:"os"`
  60. Browser string `json:"browser"`
  61. Device string `json:"device"`
  62. }
  63. // MessageCreateData represents incoming message data from Discord.
  64. type MessageCreateData struct {
  65. ID string `json:"id"`
  66. ChannelID string `json:"channel_id"`
  67. Content string `json:"content"`
  68. Author struct {
  69. ID string `json:"id"`
  70. Username string `json:"username"`
  71. Bot bool `json:"bot"`
  72. } `json:"author"`
  73. }
  74. // XrayRestartProvider abstracts restarting the core.
  75. type XrayRestartProvider interface {
  76. RestartXray(force bool) error
  77. }
  78. // GatewayClient manages the Discord Gateway WebSocket connection for interactive commands.
  79. type GatewayClient struct {
  80. discordService *DiscordService
  81. settingService service.SettingService
  82. serverService ServerProvider
  83. inboundService InboundProvider
  84. xrayService XrayRestartProvider
  85. gatewayURL string
  86. egressProxyURL func() string
  87. mu sync.Mutex
  88. writeMu sync.Mutex // gorilla panics on concurrent writes; the ticker and op 1 replies both write
  89. conn *websocket.Conn
  90. cancel context.CancelFunc
  91. running bool
  92. lastSeq *int64
  93. }
  94. // NewGatewayClient creates a new Discord Gateway client instance.
  95. func NewGatewayClient(
  96. discordService *DiscordService,
  97. settingService service.SettingService,
  98. server ServerProvider,
  99. inbound InboundProvider,
  100. xray XrayRestartProvider,
  101. ) *GatewayClient {
  102. return &GatewayClient{
  103. discordService: discordService,
  104. settingService: settingService,
  105. serverService: server,
  106. inboundService: inbound,
  107. xrayService: xray,
  108. gatewayURL: defaultGatewayURL,
  109. egressProxyURL: settingService.PanelEgressProxyURL,
  110. }
  111. }
  112. // SetGatewayURL overrides the gateway URL for testing.
  113. func (g *GatewayClient) SetGatewayURL(url string) {
  114. g.gatewayURL = url
  115. }
  116. // IsRunning reports whether the Gateway client is active.
  117. func (g *GatewayClient) IsRunning() bool {
  118. g.mu.Lock()
  119. defer g.mu.Unlock()
  120. return g.running
  121. }
  122. // Start begins the Gateway connection and listening loop.
  123. func (g *GatewayClient) Start(parentCtx context.Context) error {
  124. g.mu.Lock()
  125. if g.running {
  126. g.mu.Unlock()
  127. return nil
  128. }
  129. ctx, cancel := context.WithCancel(parentCtx)
  130. g.cancel = cancel
  131. g.running = true
  132. g.mu.Unlock()
  133. go func() {
  134. defer func() {
  135. g.mu.Lock()
  136. g.running = false
  137. g.mu.Unlock()
  138. }()
  139. for {
  140. select {
  141. case <-ctx.Done():
  142. return
  143. default:
  144. }
  145. enabled, err := g.settingService.GetDiscordBotEnable()
  146. if err != nil || !enabled {
  147. return
  148. }
  149. err = g.connectAndListen(ctx)
  150. // Discord marks these close codes non-reconnectable: a bad token or an intent not enabled in the portal.
  151. if websocket.IsCloseError(err, 4004, 4010, 4011, 4012, 4013, 4014) {
  152. logger.Warning("Discord Gateway closed for good: ", err, "; not reconnecting until the bot token changes, the bot is re-enabled or the panel restarts")
  153. return
  154. }
  155. if err != nil && ctx.Err() == nil {
  156. logger.Warning("Discord Gateway disconnected: ", err, "; reconnecting in 5s...")
  157. select {
  158. case <-ctx.Done():
  159. return
  160. case <-time.After(5 * time.Second):
  161. }
  162. }
  163. }
  164. }()
  165. return nil
  166. }
  167. // Stop terminates the Gateway connection cleanly.
  168. func (g *GatewayClient) Stop() {
  169. g.mu.Lock()
  170. defer g.mu.Unlock()
  171. if !g.running {
  172. return
  173. }
  174. if g.cancel != nil {
  175. g.cancel()
  176. }
  177. if g.conn != nil {
  178. _ = g.conn.Close()
  179. }
  180. g.running = false
  181. }
  182. func (g *GatewayClient) writeJSON(conn *websocket.Conn, v any) error {
  183. g.writeMu.Lock()
  184. defer g.writeMu.Unlock()
  185. return conn.WriteJSON(v)
  186. }
  187. func (g *GatewayClient) connectAndListen(ctx context.Context) error {
  188. token, err := g.settingService.GetDiscordBotToken()
  189. if err != nil || strings.TrimSpace(token) == "" {
  190. return errors.New("discord bot token not configured")
  191. }
  192. cleanToken := strings.TrimSpace(token)
  193. cleanToken = strings.TrimPrefix(cleanToken, "Bot ")
  194. cleanToken = strings.TrimSpace(cleanToken)
  195. dialer := *websocket.DefaultDialer
  196. if raw := g.egressProxyURL(); raw != "" {
  197. proxyURL, err := url.Parse(raw)
  198. if err != nil {
  199. return fmt.Errorf("parse panel egress proxy: %w", err)
  200. }
  201. dialer.Proxy = http.ProxyURL(proxyURL)
  202. }
  203. conn, resp, err := dialer.DialContext(ctx, g.gatewayURL, nil)
  204. if err != nil {
  205. if resp != nil && resp.Body != nil {
  206. _ = resp.Body.Close()
  207. }
  208. return fmt.Errorf("dial discord gateway: %w", err)
  209. }
  210. g.mu.Lock()
  211. g.conn = conn
  212. g.mu.Unlock()
  213. defer func() {
  214. _ = conn.Close()
  215. g.mu.Lock()
  216. if g.conn == conn {
  217. g.conn = nil
  218. }
  219. g.mu.Unlock()
  220. }()
  221. // 1. Read Hello opcode 10
  222. var helloPayload GatewayPayload
  223. if err := conn.ReadJSON(&helloPayload); err != nil {
  224. return fmt.Errorf("read hello payload: %w", err)
  225. }
  226. if helloPayload.Op != opHello {
  227. return fmt.Errorf("expected opcode 10, got %d", helloPayload.Op)
  228. }
  229. var helloData HelloData
  230. if err := json.Unmarshal(helloPayload.D, &helloData); err != nil {
  231. return fmt.Errorf("unmarshal hello data: %w", err)
  232. }
  233. // 2. Send Identify opcode 2
  234. identifyPayload := GatewayPayload{
  235. Op: opIdentify,
  236. }
  237. identData := IdentifyData{
  238. Token: "Bot " + cleanToken,
  239. Intents: discordIntents,
  240. Properties: IdentifyProperties{
  241. OS: "linux",
  242. Browser: "3x-ui",
  243. Device: "3x-ui",
  244. },
  245. }
  246. dataBytes, _ := json.Marshal(identData)
  247. identifyPayload.D = dataBytes
  248. if err := conn.WriteJSON(identifyPayload); err != nil {
  249. return fmt.Errorf("send identify payload: %w", err)
  250. }
  251. // 3. Heartbeat loop
  252. hbStop := make(chan struct{})
  253. defer close(hbStop)
  254. // Discord answers every heartbeat with op 11; a half-open socket keeps taking
  255. // writes and never answers, so a missing ACK means this one must be dropped.
  256. var acked atomic.Bool
  257. acked.Store(true)
  258. go func() {
  259. interval := time.Duration(helloData.HeartbeatInterval) * time.Millisecond
  260. if interval <= 0 {
  261. interval = 40 * time.Second
  262. }
  263. ticker := time.NewTicker(interval)
  264. defer ticker.Stop()
  265. for {
  266. select {
  267. case <-hbStop:
  268. return
  269. case <-ctx.Done():
  270. return
  271. case <-ticker.C:
  272. if !acked.Swap(false) {
  273. logger.Warning("Discord heartbeats went unanswered; dropping the zombied gateway connection")
  274. _ = conn.Close()
  275. return
  276. }
  277. g.mu.Lock()
  278. seq := g.lastSeq
  279. c := g.conn
  280. g.mu.Unlock()
  281. if c == nil {
  282. return
  283. }
  284. hb := GatewayPayload{Op: opHeartbeat}
  285. if seq != nil {
  286. seqBytes, _ := json.Marshal(*seq)
  287. hb.D = seqBytes
  288. }
  289. if err := g.writeJSON(c, hb); err != nil {
  290. logger.Warning("Discord heartbeat write failed: ", err)
  291. return
  292. }
  293. }
  294. }
  295. }()
  296. // 4. Message dispatch loop
  297. for {
  298. select {
  299. case <-ctx.Done():
  300. return nil
  301. default:
  302. }
  303. var payload GatewayPayload
  304. if err := conn.ReadJSON(&payload); err != nil {
  305. return err
  306. }
  307. if payload.S != nil {
  308. g.mu.Lock()
  309. g.lastSeq = payload.S
  310. g.mu.Unlock()
  311. }
  312. switch payload.Op {
  313. case opHeartbeatACK:
  314. acked.Store(true)
  315. case opHeartbeat:
  316. // Discord requested immediate heartbeat
  317. g.mu.Lock()
  318. seq := g.lastSeq
  319. g.mu.Unlock()
  320. hb := GatewayPayload{Op: opHeartbeat}
  321. if seq != nil {
  322. seqBytes, _ := json.Marshal(*seq)
  323. hb.D = seqBytes
  324. }
  325. _ = g.writeJSON(conn, hb)
  326. case opDispatch:
  327. if payload.T == "MESSAGE_CREATE" {
  328. var msg MessageCreateData
  329. if err := json.Unmarshal(payload.D, &msg); err == nil {
  330. go func(m MessageCreateData) {
  331. defer func() {
  332. if r := recover(); r != nil {
  333. logger.Error("Recovered panic in Discord message handler: ", r)
  334. }
  335. }()
  336. g.handleMessage(ctx, m)
  337. }(msg)
  338. }
  339. }
  340. }
  341. }
  342. }
  343. func (g *GatewayClient) handleMessage(ctx context.Context, msg MessageCreateData) {
  344. if msg.Author.Bot {
  345. return
  346. }
  347. channelID, err := g.settingService.GetDiscordChannelId()
  348. if err != nil || strings.TrimSpace(channelID) == "" {
  349. return
  350. }
  351. if msg.ChannelID != strings.TrimSpace(channelID) {
  352. return
  353. }
  354. content := strings.TrimSpace(msg.Content)
  355. if !strings.HasPrefix(content, "!") && !strings.HasPrefix(content, "/") {
  356. return
  357. }
  358. if !g.isAdmin(msg.Author.ID) {
  359. return
  360. }
  361. parts := strings.Fields(content)
  362. if len(parts) == 0 {
  363. return
  364. }
  365. cmd := strings.ToLower(parts[0])
  366. cmd = strings.TrimLeft(cmd, "!/")
  367. args := parts[1:]
  368. switch cmd {
  369. case "help", "start":
  370. g.sendHelp(ctx)
  371. case "status":
  372. g.sendStatus(ctx)
  373. case "report":
  374. _ = g.discordService.SendReport(ctx, g.serverService, g.inboundService)
  375. case "backup":
  376. g.sendBackup(ctx)
  377. case "usage":
  378. if len(args) == 0 {
  379. _ = g.discordService.SendMessage(ctx, MessagePayload{
  380. Content: translator(g.settingService)("discord.commands.usageHint"),
  381. })
  382. return
  383. }
  384. g.sendUsage(ctx, args[0])
  385. case "inbounds":
  386. g.sendInbounds(ctx)
  387. case "restart":
  388. g.restartXray(ctx)
  389. }
  390. }
  391. // isAdmin reports whether a Discord user is listed in discordAdminIds; an empty list admits nobody.
  392. func (g *GatewayClient) isAdmin(userID string) bool {
  393. ids, err := g.settingService.GetDiscordAdminIds()
  394. if err != nil {
  395. return false
  396. }
  397. for id := range strings.SplitSeq(ids, ",") {
  398. if id = strings.TrimSpace(id); id != "" && id == userID {
  399. return true
  400. }
  401. }
  402. return false
  403. }
  404. func (g *GatewayClient) sendHelp(ctx context.Context) {
  405. tr := translator(g.settingService)
  406. embed := Embed{
  407. Title: tr("discord.commands.helpTitle"),
  408. Description: tr("discord.commands.helpDescription"),
  409. Color: ColorBlue,
  410. Timestamp: time.Now().UTC().Format(time.RFC3339),
  411. Fields: []EmbedField{
  412. {Name: "!status", Value: tr("discord.commands.helpStatus"), Inline: false},
  413. {Name: "!report", Value: tr("discord.commands.helpReport"), Inline: false},
  414. {Name: "!backup", Value: tr("discord.commands.helpBackup"), Inline: false},
  415. {Name: "!usage <email>", Value: tr("discord.commands.helpUsage"), Inline: false},
  416. {Name: "!inbounds", Value: tr("discord.commands.helpInbounds"), Inline: false},
  417. {Name: "!restart", Value: tr("discord.commands.helpRestart"), Inline: false},
  418. {Name: "!help", Value: tr("discord.commands.helpHelp"), Inline: false},
  419. },
  420. Footer: &EmbedFooter{Text: tr("discord.footer")},
  421. }
  422. _ = g.discordService.SendEmbed(ctx, embed)
  423. }
  424. func (g *GatewayClient) sendStatus(ctx context.Context) {
  425. var status *service.Status
  426. if g.serverService != nil {
  427. status = g.serverService.GetStatus(nil)
  428. }
  429. if status == nil {
  430. status = &service.Status{}
  431. }
  432. hostname, _ := os.Hostname()
  433. if hostname == "" {
  434. hostname = "3x-ui"
  435. }
  436. days := status.Uptime / 86400
  437. hours := (status.Uptime % 86400) / 3600
  438. var onlines []string
  439. if process := service.XrayProcess(); process != nil && process.IsRunning() {
  440. onlines = process.GetOnlineClients()
  441. }
  442. load1, load2, load3 := 0.0, 0.0, 0.0
  443. if len(status.Loads) > 0 {
  444. load1 = status.Loads[0]
  445. }
  446. if len(status.Loads) > 1 {
  447. load2 = status.Loads[1]
  448. }
  449. if len(status.Loads) > 2 {
  450. load3 = status.Loads[2]
  451. }
  452. tr := translator(g.settingService)
  453. embed := Embed{
  454. Title: tr("discord.commands.statusTitle"),
  455. Description: tr("discord.commands.statusDescription", "Host=="+hostname),
  456. Color: ColorGreen,
  457. Timestamp: time.Now().UTC().Format(time.RFC3339),
  458. Fields: []EmbedField{
  459. {Name: tr("discord.fields.panelVersion"), Value: config.GetPanelVersion(), Inline: true},
  460. {Name: tr("discord.fields.xrayCore"), Value: fmt.Sprintf("%s (%s)", status.Xray.Version, status.Xray.State), Inline: true},
  461. {Name: tr("pages.index.uptime"), Value: tr("discord.values.uptime", "Days=="+fmt.Sprint(days), "Hours=="+fmt.Sprint(hours)), Inline: true},
  462. {Name: tr("discord.fields.systemLoad"), Value: fmt.Sprintf("%.2f, %.2f, %.2f", load1, load2, load3), Inline: true},
  463. {Name: tr("pages.index.memory"), Value: fmt.Sprintf("%s / %s", common.FormatTraffic(int64(status.Mem.Current)), common.FormatTraffic(int64(status.Mem.Total))), Inline: true},
  464. {Name: tr("pages.index.historyTitleOnline"), Value: strconv.Itoa(len(onlines)), Inline: true},
  465. {Name: tr("pages.index.historyTabConnections"), Value: fmt.Sprintf("TCP: %d | UDP: %d", status.TcpCount, status.UdpCount), Inline: true},
  466. {Name: tr("pages.index.sent"), Value: common.FormatTraffic(int64(status.NetTraffic.Sent)), Inline: true},
  467. {Name: tr("pages.index.received"), Value: common.FormatTraffic(int64(status.NetTraffic.Recv)), Inline: true},
  468. },
  469. Footer: &EmbedFooter{Text: tr("discord.footer")},
  470. }
  471. _ = g.discordService.SendEmbed(ctx, embed)
  472. }
  473. func (g *GatewayClient) sendBackup(ctx context.Context) {
  474. tr := translator(g.settingService)
  475. if g.serverService == nil {
  476. _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.backupUnavailable")})
  477. return
  478. }
  479. dbData, err := g.serverService.GetDb()
  480. if err != nil || len(dbData) == 0 {
  481. _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.backupFailed", "Error=="+fmt.Sprint(err))})
  482. return
  483. }
  484. filename := g.serverService.BackupFilename("")
  485. if filename == "" {
  486. filename = "x-ui.db"
  487. }
  488. files := []FileAttachment{
  489. {Filename: filename, Data: dbData},
  490. }
  491. configPath := xray.GetConfigPath()
  492. if configData, err := os.ReadFile(configPath); err == nil && len(configData) > 0 {
  493. files = append(files, FileAttachment{
  494. Filename: "config.json",
  495. Data: configData,
  496. })
  497. }
  498. payload := MessagePayload{
  499. Embeds: []Embed{
  500. {
  501. Title: tr("discord.commands.backupTitle"),
  502. Description: tr("discord.commands.backupDescription", "Time=="+time.Now().UTC().Format(time.RFC3339)),
  503. Color: ColorBlue,
  504. Timestamp: time.Now().UTC().Format(time.RFC3339),
  505. Footer: &EmbedFooter{Text: tr("discord.footer")},
  506. },
  507. },
  508. }
  509. _ = g.discordService.SendMessageWithFiles(ctx, payload, files...)
  510. }
  511. func (g *GatewayClient) sendUsage(ctx context.Context, email string) {
  512. tr := translator(g.settingService)
  513. if g.inboundService == nil {
  514. _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsUnavailable")})
  515. return
  516. }
  517. inbounds, err := g.inboundService.GetAllInbounds()
  518. if err != nil {
  519. _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsFailed", "Error=="+err.Error())})
  520. return
  521. }
  522. target := strings.ToLower(strings.TrimSpace(email))
  523. for _, in := range inbounds {
  524. for _, client := range in.ClientStats {
  525. if strings.ToLower(client.Email) == target {
  526. color := ColorGreen
  527. statusStr := tr("enabled")
  528. if !client.Enable {
  529. color = ColorRed
  530. statusStr = tr("disabled")
  531. }
  532. expireStr := tr("unlimited")
  533. switch {
  534. case client.ExpiryTime > 0:
  535. expireStr = time.Unix(client.ExpiryTime/1000, 0).Format("2006-01-02 15:04:05")
  536. // Start After First Use stores the duration negated, so such a client is
  537. // not unlimited: it starts counting down on its first connection.
  538. case client.ExpiryTime < 0:
  539. expireStr = fmt.Sprintf("%d %s", client.ExpiryTime/-86400000, tr("tgbot.days"))
  540. }
  541. totalLimitStr := tr("unlimited")
  542. if client.Total > 0 {
  543. totalLimitStr = common.FormatTraffic(client.Total)
  544. }
  545. embed := Embed{
  546. Title: tr("discord.commands.usageTitle", "Email=="+client.Email),
  547. Description: tr("discord.commands.usageDescription", "Remark=="+in.Remark, "Port=="+strconv.Itoa(in.Port)),
  548. Color: color,
  549. Timestamp: time.Now().UTC().Format(time.RFC3339),
  550. Fields: []EmbedField{
  551. {Name: tr("status"), Value: statusStr, Inline: true},
  552. {Name: tr("pages.index.upload"), Value: common.FormatTraffic(client.Up), Inline: true},
  553. {Name: tr("pages.index.download"), Value: common.FormatTraffic(client.Down), Inline: true},
  554. {Name: tr("discord.fields.totalUsed"), Value: common.FormatTraffic(client.Up + client.Down), Inline: true},
  555. {Name: tr("discord.fields.quota"), Value: totalLimitStr, Inline: true},
  556. {Name: tr("pages.clients.expiryTime"), Value: expireStr, Inline: true},
  557. },
  558. Footer: &EmbedFooter{Text: tr("discord.footer")},
  559. }
  560. _ = g.discordService.SendEmbed(ctx, embed)
  561. return
  562. }
  563. }
  564. }
  565. _ = g.discordService.SendMessage(ctx, MessagePayload{
  566. Content: tr("discord.commands.clientNotFound", "Email=="+email),
  567. })
  568. }
  569. func (g *GatewayClient) sendInbounds(ctx context.Context) {
  570. tr := translator(g.settingService)
  571. if g.inboundService == nil {
  572. _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsUnavailable")})
  573. return
  574. }
  575. inbounds, err := g.inboundService.GetAllInbounds()
  576. if err != nil {
  577. _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsFailed", "Error=="+err.Error())})
  578. return
  579. }
  580. if len(inbounds) == 0 {
  581. _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.noInbounds")})
  582. return
  583. }
  584. var fields []EmbedField
  585. for _, in := range inbounds {
  586. state := tr("enabled")
  587. if !in.Enable {
  588. state = tr("disabled")
  589. }
  590. val := tr("discord.values.inbound",
  591. "Protocol=="+string(in.Protocol),
  592. "Port=="+strconv.Itoa(in.Port),
  593. "Clients=="+strconv.Itoa(len(in.ClientStats)),
  594. "Up=="+common.FormatTraffic(in.Up),
  595. "Down=="+common.FormatTraffic(in.Down),
  596. "State=="+state,
  597. )
  598. fields = append(fields, cleanField(fmt.Sprintf("📍 %s", in.Remark), val, false))
  599. }
  600. title := tr("discord.commands.inboundsTitle")
  601. description := tr("discord.commands.inboundsDescription", "Count=="+strconv.Itoa(len(inbounds)))
  602. footer := tr("discord.footer")
  603. overhead := discordCharLen(title) + discordCharLen(description) + discordCharLen(footer)
  604. now := time.Now().UTC().Format(time.RFC3339)
  605. for _, group := range splitInboundFields(fields, overhead) {
  606. embeds := make([]Embed, 0, len(group)/discordEmbedFieldLimit+1)
  607. for start := 0; start < len(group); start += discordEmbedFieldLimit {
  608. embed := Embed{
  609. Color: ColorBlue,
  610. Timestamp: now,
  611. Fields: group[start:min(start+discordEmbedFieldLimit, len(group))],
  612. }
  613. // The header leads the reply only once per message; later embeds of a
  614. // paged panel would otherwise repeat it for every 25 inbounds.
  615. if len(embeds) == 0 {
  616. embed.Title = title
  617. embed.Description = description
  618. embed.Footer = &EmbedFooter{Text: footer}
  619. }
  620. embeds = append(embeds, embed)
  621. }
  622. payload := MessagePayload{Embeds: embeds}
  623. err := g.discordService.SendMessage(ctx, payload)
  624. var limited *RateLimitedError
  625. if errors.As(err, &limited) && limited.RetryAfter > 0 && limited.RetryAfter <= discordRateLimitWait {
  626. select {
  627. case <-ctx.Done():
  628. return
  629. case <-time.After(limited.RetryAfter):
  630. }
  631. err = g.discordService.SendMessage(ctx, payload)
  632. }
  633. // One page Discord refused must not take the pages behind it down: the
  634. // operator is better served by a partial list than by nothing at all.
  635. if err != nil {
  636. logger.Warning("Discord inbounds command: send failed: ", err)
  637. }
  638. }
  639. }
  640. // splitInboundFields packs fields into groups that each fit one Discord message,
  641. // within the character count Discord counts across its embeds and its embed cap.
  642. func splitInboundFields(fields []EmbedField, overhead int) [][]EmbedField {
  643. groups := make([][]EmbedField, 0, 1)
  644. group := make([]EmbedField, 0, discordEmbedFieldLimit)
  645. chars := overhead
  646. for _, field := range fields {
  647. size := discordCharLen(field.Name) + discordCharLen(field.Value)
  648. if len(group) > 0 && (len(group) >= discordEmbedFieldLimit*discordEmbedsPerMsg || chars+size > discordMessageCharLimit) {
  649. groups = append(groups, group)
  650. group = make([]EmbedField, 0, discordEmbedFieldLimit)
  651. chars = overhead
  652. }
  653. group = append(group, field)
  654. chars += size
  655. }
  656. if len(group) > 0 {
  657. groups = append(groups, group)
  658. }
  659. return groups
  660. }
  661. func (g *GatewayClient) restartXray(ctx context.Context) {
  662. tr := translator(g.settingService)
  663. if g.xrayService == nil {
  664. _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.xrayUnavailable")})
  665. return
  666. }
  667. _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.restarting")})
  668. if err := g.xrayService.RestartXray(false); err != nil {
  669. _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.restartFailed", "Error=="+err.Error())})
  670. } else {
  671. _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.restartSuccess")})
  672. }
  673. }