| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678 |
- package discord
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "os"
- "strconv"
- "strings"
- "sync"
- "time"
- "github.com/gorilla/websocket"
- "github.com/mhsanaei/3x-ui/v3/internal/config"
- "github.com/mhsanaei/3x-ui/v3/internal/logger"
- "github.com/mhsanaei/3x-ui/v3/internal/util/common"
- "github.com/mhsanaei/3x-ui/v3/internal/web/service"
- "github.com/mhsanaei/3x-ui/v3/internal/xray"
- )
- const (
- defaultGatewayURL = "wss://gateway.discord.gg/?v=10&encoding=json"
- opDispatch = 0
- opHeartbeat = 1
- opIdentify = 2
- opHello = 10
- opHeartbeatACK = 11
- // GUILDS (1<<0) | GUILD_MESSAGES (1<<9) | DIRECT_MESSAGES (1<<12) | MESSAGE_CONTENT (1<<15)
- discordIntents = 37377
- )
- // GatewayPayload represents a Discord Gateway WebSocket frame.
- type GatewayPayload struct {
- Op int `json:"op"`
- D json.RawMessage `json:"d,omitempty"`
- S *int64 `json:"s,omitempty"`
- T string `json:"t,omitempty"`
- }
- // HelloData represents the payload received in Opcode 10 Hello.
- type HelloData struct {
- HeartbeatInterval int `json:"heartbeat_interval"`
- }
- // IdentifyData represents the payload sent in Opcode 2 Identify.
- type IdentifyData struct {
- Token string `json:"token"`
- Intents int `json:"intents"`
- Properties IdentifyProperties `json:"properties"`
- }
- // IdentifyProperties metadata for Discord identification.
- type IdentifyProperties struct {
- OS string `json:"os"`
- Browser string `json:"browser"`
- Device string `json:"device"`
- }
- // MessageCreateData represents incoming message data from Discord.
- type MessageCreateData struct {
- ID string `json:"id"`
- ChannelID string `json:"channel_id"`
- Content string `json:"content"`
- Author struct {
- ID string `json:"id"`
- Username string `json:"username"`
- Bot bool `json:"bot"`
- } `json:"author"`
- }
- // XrayRestartProvider abstracts restarting the core.
- type XrayRestartProvider interface {
- RestartXray(force bool) error
- }
- // GatewayClient manages the Discord Gateway WebSocket connection for interactive commands.
- type GatewayClient struct {
- discordService *DiscordService
- settingService service.SettingService
- serverService ServerProvider
- inboundService InboundProvider
- xrayService XrayRestartProvider
- gatewayURL string
- egressProxyURL func() string
- mu sync.Mutex
- writeMu sync.Mutex // gorilla panics on concurrent writes; the ticker and op 1 replies both write
- conn *websocket.Conn
- cancel context.CancelFunc
- running bool
- lastSeq *int64
- }
- // NewGatewayClient creates a new Discord Gateway client instance.
- func NewGatewayClient(
- discordService *DiscordService,
- settingService service.SettingService,
- server ServerProvider,
- inbound InboundProvider,
- xray XrayRestartProvider,
- ) *GatewayClient {
- return &GatewayClient{
- discordService: discordService,
- settingService: settingService,
- serverService: server,
- inboundService: inbound,
- xrayService: xray,
- gatewayURL: defaultGatewayURL,
- egressProxyURL: settingService.PanelEgressProxyURL,
- }
- }
- // SetGatewayURL overrides the gateway URL for testing.
- func (g *GatewayClient) SetGatewayURL(url string) {
- g.gatewayURL = url
- }
- // IsRunning reports whether the Gateway client is active.
- func (g *GatewayClient) IsRunning() bool {
- g.mu.Lock()
- defer g.mu.Unlock()
- return g.running
- }
- // Start begins the Gateway connection and listening loop.
- func (g *GatewayClient) Start(parentCtx context.Context) error {
- g.mu.Lock()
- if g.running {
- g.mu.Unlock()
- return nil
- }
- ctx, cancel := context.WithCancel(parentCtx)
- g.cancel = cancel
- g.running = true
- g.mu.Unlock()
- go func() {
- defer func() {
- g.mu.Lock()
- g.running = false
- g.mu.Unlock()
- }()
- for {
- select {
- case <-ctx.Done():
- return
- default:
- }
- enabled, err := g.settingService.GetDiscordBotEnable()
- if err != nil || !enabled {
- return
- }
- err = g.connectAndListen(ctx)
- // Discord marks these close codes non-reconnectable: a bad token or an intent not enabled in the portal.
- if websocket.IsCloseError(err, 4004, 4010, 4011, 4012, 4013, 4014) {
- logger.Warning("Discord Gateway closed for good: ", err, "; not reconnecting until the bot token changes, the bot is re-enabled or the panel restarts")
- return
- }
- if err != nil && ctx.Err() == nil {
- logger.Warning("Discord Gateway disconnected: ", err, "; reconnecting in 5s...")
- select {
- case <-ctx.Done():
- return
- case <-time.After(5 * time.Second):
- }
- }
- }
- }()
- return nil
- }
- // Stop terminates the Gateway connection cleanly.
- func (g *GatewayClient) Stop() {
- g.mu.Lock()
- defer g.mu.Unlock()
- if !g.running {
- return
- }
- if g.cancel != nil {
- g.cancel()
- }
- if g.conn != nil {
- _ = g.conn.Close()
- }
- g.running = false
- }
- func (g *GatewayClient) writeJSON(conn *websocket.Conn, v any) error {
- g.writeMu.Lock()
- defer g.writeMu.Unlock()
- return conn.WriteJSON(v)
- }
- func (g *GatewayClient) connectAndListen(ctx context.Context) error {
- token, err := g.settingService.GetDiscordBotToken()
- if err != nil || strings.TrimSpace(token) == "" {
- return errors.New("discord bot token not configured")
- }
- cleanToken := strings.TrimSpace(token)
- cleanToken = strings.TrimPrefix(cleanToken, "Bot ")
- cleanToken = strings.TrimSpace(cleanToken)
- dialer := *websocket.DefaultDialer
- if raw := g.egressProxyURL(); raw != "" {
- proxyURL, err := url.Parse(raw)
- if err != nil {
- return fmt.Errorf("parse panel egress proxy: %w", err)
- }
- dialer.Proxy = http.ProxyURL(proxyURL)
- }
- conn, resp, err := dialer.DialContext(ctx, g.gatewayURL, nil)
- if err != nil {
- if resp != nil && resp.Body != nil {
- _ = resp.Body.Close()
- }
- return fmt.Errorf("dial discord gateway: %w", err)
- }
- g.mu.Lock()
- g.conn = conn
- g.mu.Unlock()
- defer func() {
- _ = conn.Close()
- g.mu.Lock()
- if g.conn == conn {
- g.conn = nil
- }
- g.mu.Unlock()
- }()
- // 1. Read Hello opcode 10
- var helloPayload GatewayPayload
- if err := conn.ReadJSON(&helloPayload); err != nil {
- return fmt.Errorf("read hello payload: %w", err)
- }
- if helloPayload.Op != opHello {
- return fmt.Errorf("expected opcode 10, got %d", helloPayload.Op)
- }
- var helloData HelloData
- if err := json.Unmarshal(helloPayload.D, &helloData); err != nil {
- return fmt.Errorf("unmarshal hello data: %w", err)
- }
- // 2. Send Identify opcode 2
- identifyPayload := GatewayPayload{
- Op: opIdentify,
- }
- identData := IdentifyData{
- Token: "Bot " + cleanToken,
- Intents: discordIntents,
- Properties: IdentifyProperties{
- OS: "linux",
- Browser: "3x-ui",
- Device: "3x-ui",
- },
- }
- dataBytes, _ := json.Marshal(identData)
- identifyPayload.D = dataBytes
- if err := conn.WriteJSON(identifyPayload); err != nil {
- return fmt.Errorf("send identify payload: %w", err)
- }
- // 3. Heartbeat loop
- hbStop := make(chan struct{})
- defer close(hbStop)
- go func() {
- interval := time.Duration(helloData.HeartbeatInterval) * time.Millisecond
- if interval <= 0 {
- interval = 40 * time.Second
- }
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
- for {
- select {
- case <-hbStop:
- return
- case <-ctx.Done():
- return
- case <-ticker.C:
- g.mu.Lock()
- seq := g.lastSeq
- c := g.conn
- g.mu.Unlock()
- if c == nil {
- return
- }
- hb := GatewayPayload{Op: opHeartbeat}
- if seq != nil {
- seqBytes, _ := json.Marshal(*seq)
- hb.D = seqBytes
- }
- if err := g.writeJSON(c, hb); err != nil {
- logger.Warning("Discord heartbeat write failed: ", err)
- return
- }
- }
- }
- }()
- // 4. Message dispatch loop
- for {
- select {
- case <-ctx.Done():
- return nil
- default:
- }
- var payload GatewayPayload
- if err := conn.ReadJSON(&payload); err != nil {
- return err
- }
- if payload.S != nil {
- g.mu.Lock()
- g.lastSeq = payload.S
- g.mu.Unlock()
- }
- switch payload.Op {
- case opHeartbeatACK:
- // Heartbeat acknowledged
- case opHeartbeat:
- // Discord requested immediate heartbeat
- g.mu.Lock()
- seq := g.lastSeq
- g.mu.Unlock()
- hb := GatewayPayload{Op: opHeartbeat}
- if seq != nil {
- seqBytes, _ := json.Marshal(*seq)
- hb.D = seqBytes
- }
- _ = g.writeJSON(conn, hb)
- case opDispatch:
- if payload.T == "MESSAGE_CREATE" {
- var msg MessageCreateData
- if err := json.Unmarshal(payload.D, &msg); err == nil {
- go func(m MessageCreateData) {
- defer func() {
- if r := recover(); r != nil {
- logger.Error("Recovered panic in Discord message handler: ", r)
- }
- }()
- g.handleMessage(ctx, m)
- }(msg)
- }
- }
- }
- }
- }
- func (g *GatewayClient) handleMessage(ctx context.Context, msg MessageCreateData) {
- if msg.Author.Bot {
- return
- }
- channelID, err := g.settingService.GetDiscordChannelId()
- if err != nil || strings.TrimSpace(channelID) == "" {
- return
- }
- if msg.ChannelID != strings.TrimSpace(channelID) {
- return
- }
- content := strings.TrimSpace(msg.Content)
- if !strings.HasPrefix(content, "!") && !strings.HasPrefix(content, "/") {
- return
- }
- if !g.isAdmin(msg.Author.ID) {
- return
- }
- parts := strings.Fields(content)
- if len(parts) == 0 {
- return
- }
- cmd := strings.ToLower(parts[0])
- cmd = strings.TrimLeft(cmd, "!/")
- args := parts[1:]
- switch cmd {
- case "help", "start":
- g.sendHelp(ctx)
- case "status":
- g.sendStatus(ctx)
- case "report":
- _ = g.discordService.SendReport(ctx, g.serverService, g.inboundService)
- case "backup":
- g.sendBackup(ctx)
- case "usage":
- if len(args) == 0 {
- _ = g.discordService.SendMessage(ctx, MessagePayload{
- Content: translator(g.settingService)("discord.commands.usageHint"),
- })
- return
- }
- g.sendUsage(ctx, args[0])
- case "inbounds":
- g.sendInbounds(ctx)
- case "restart":
- g.restartXray(ctx)
- }
- }
- // isAdmin reports whether a Discord user is listed in discordAdminIds; an empty list admits nobody.
- func (g *GatewayClient) isAdmin(userID string) bool {
- ids, err := g.settingService.GetDiscordAdminIds()
- if err != nil {
- return false
- }
- for id := range strings.SplitSeq(ids, ",") {
- if id = strings.TrimSpace(id); id != "" && id == userID {
- return true
- }
- }
- return false
- }
- func (g *GatewayClient) sendHelp(ctx context.Context) {
- tr := translator(g.settingService)
- embed := Embed{
- Title: tr("discord.commands.helpTitle"),
- Description: tr("discord.commands.helpDescription"),
- Color: ColorBlue,
- Timestamp: time.Now().UTC().Format(time.RFC3339),
- Fields: []EmbedField{
- {Name: "!status", Value: tr("discord.commands.helpStatus"), Inline: false},
- {Name: "!report", Value: tr("discord.commands.helpReport"), Inline: false},
- {Name: "!backup", Value: tr("discord.commands.helpBackup"), Inline: false},
- {Name: "!usage <email>", Value: tr("discord.commands.helpUsage"), Inline: false},
- {Name: "!inbounds", Value: tr("discord.commands.helpInbounds"), Inline: false},
- {Name: "!restart", Value: tr("discord.commands.helpRestart"), Inline: false},
- {Name: "!help", Value: tr("discord.commands.helpHelp"), Inline: false},
- },
- Footer: &EmbedFooter{Text: tr("discord.footer")},
- }
- _ = g.discordService.SendEmbed(ctx, embed)
- }
- func (g *GatewayClient) sendStatus(ctx context.Context) {
- var status *service.Status
- if g.serverService != nil {
- status = g.serverService.GetStatus(nil)
- }
- if status == nil {
- status = &service.Status{}
- }
- hostname, _ := os.Hostname()
- if hostname == "" {
- hostname = "3x-ui"
- }
- days := status.Uptime / 86400
- hours := (status.Uptime % 86400) / 3600
- var onlines []string
- if process := service.XrayProcess(); process != nil && process.IsRunning() {
- onlines = process.GetOnlineClients()
- }
- load1, load2, load3 := 0.0, 0.0, 0.0
- if len(status.Loads) > 0 {
- load1 = status.Loads[0]
- }
- if len(status.Loads) > 1 {
- load2 = status.Loads[1]
- }
- if len(status.Loads) > 2 {
- load3 = status.Loads[2]
- }
- tr := translator(g.settingService)
- embed := Embed{
- Title: tr("discord.commands.statusTitle"),
- Description: tr("discord.commands.statusDescription", "Host=="+hostname),
- Color: ColorGreen,
- Timestamp: time.Now().UTC().Format(time.RFC3339),
- Fields: []EmbedField{
- {Name: tr("discord.fields.panelVersion"), Value: config.GetPanelVersion(), Inline: true},
- {Name: tr("discord.fields.xrayCore"), Value: fmt.Sprintf("%s (%s)", status.Xray.Version, status.Xray.State), Inline: true},
- {Name: tr("pages.index.uptime"), Value: tr("discord.values.uptime", "Days=="+fmt.Sprint(days), "Hours=="+fmt.Sprint(hours)), Inline: true},
- {Name: tr("discord.fields.systemLoad"), Value: fmt.Sprintf("%.2f, %.2f, %.2f", load1, load2, load3), Inline: true},
- {Name: tr("pages.index.memory"), Value: fmt.Sprintf("%s / %s", common.FormatTraffic(int64(status.Mem.Current)), common.FormatTraffic(int64(status.Mem.Total))), Inline: true},
- {Name: tr("pages.index.historyTitleOnline"), Value: strconv.Itoa(len(onlines)), Inline: true},
- {Name: tr("pages.index.historyTabConnections"), Value: fmt.Sprintf("TCP: %d | UDP: %d", status.TcpCount, status.UdpCount), Inline: true},
- {Name: tr("pages.index.sent"), Value: common.FormatTraffic(int64(status.NetTraffic.Sent)), Inline: true},
- {Name: tr("pages.index.received"), Value: common.FormatTraffic(int64(status.NetTraffic.Recv)), Inline: true},
- },
- Footer: &EmbedFooter{Text: tr("discord.footer")},
- }
- _ = g.discordService.SendEmbed(ctx, embed)
- }
- func (g *GatewayClient) sendBackup(ctx context.Context) {
- tr := translator(g.settingService)
- if g.serverService == nil {
- _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.backupUnavailable")})
- return
- }
- dbData, err := g.serverService.GetDb()
- if err != nil || len(dbData) == 0 {
- _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.backupFailed", "Error=="+fmt.Sprint(err))})
- return
- }
- filename := g.serverService.BackupFilename("")
- if filename == "" {
- filename = "x-ui.db"
- }
- files := []FileAttachment{
- {Filename: filename, Data: dbData},
- }
- configPath := xray.GetConfigPath()
- if configData, err := os.ReadFile(configPath); err == nil && len(configData) > 0 {
- files = append(files, FileAttachment{
- Filename: "config.json",
- Data: configData,
- })
- }
- payload := MessagePayload{
- Embeds: []Embed{
- {
- Title: tr("discord.commands.backupTitle"),
- Description: tr("discord.commands.backupDescription", "Time=="+time.Now().UTC().Format(time.RFC3339)),
- Color: ColorBlue,
- Timestamp: time.Now().UTC().Format(time.RFC3339),
- Footer: &EmbedFooter{Text: tr("discord.footer")},
- },
- },
- }
- _ = g.discordService.SendMessageWithFiles(ctx, payload, files...)
- }
- func (g *GatewayClient) sendUsage(ctx context.Context, email string) {
- tr := translator(g.settingService)
- if g.inboundService == nil {
- _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsUnavailable")})
- return
- }
- inbounds, err := g.inboundService.GetAllInbounds()
- if err != nil {
- _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsFailed", "Error=="+err.Error())})
- return
- }
- target := strings.ToLower(strings.TrimSpace(email))
- for _, in := range inbounds {
- for _, client := range in.ClientStats {
- if strings.ToLower(client.Email) == target {
- color := ColorGreen
- statusStr := tr("enabled")
- if !client.Enable {
- color = ColorRed
- statusStr = tr("disabled")
- }
- expireStr := tr("unlimited")
- if client.ExpiryTime > 0 {
- expireStr = time.Unix(client.ExpiryTime/1000, 0).Format("2006-01-02 15:04:05")
- }
- totalLimitStr := tr("unlimited")
- if client.Total > 0 {
- totalLimitStr = common.FormatTraffic(client.Total)
- }
- embed := Embed{
- Title: tr("discord.commands.usageTitle", "Email=="+client.Email),
- Description: tr("discord.commands.usageDescription", "Remark=="+in.Remark, "Port=="+strconv.Itoa(in.Port)),
- Color: color,
- Timestamp: time.Now().UTC().Format(time.RFC3339),
- Fields: []EmbedField{
- {Name: tr("status"), Value: statusStr, Inline: true},
- {Name: tr("pages.index.upload"), Value: common.FormatTraffic(client.Up), Inline: true},
- {Name: tr("pages.index.download"), Value: common.FormatTraffic(client.Down), Inline: true},
- {Name: tr("discord.fields.totalUsed"), Value: common.FormatTraffic(client.Up + client.Down), Inline: true},
- {Name: tr("discord.fields.quota"), Value: totalLimitStr, Inline: true},
- {Name: tr("pages.clients.expiryTime"), Value: expireStr, Inline: true},
- },
- Footer: &EmbedFooter{Text: tr("discord.footer")},
- }
- _ = g.discordService.SendEmbed(ctx, embed)
- return
- }
- }
- }
- _ = g.discordService.SendMessage(ctx, MessagePayload{
- Content: tr("discord.commands.clientNotFound", "Email=="+email),
- })
- }
- func (g *GatewayClient) sendInbounds(ctx context.Context) {
- tr := translator(g.settingService)
- if g.inboundService == nil {
- _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsUnavailable")})
- return
- }
- inbounds, err := g.inboundService.GetAllInbounds()
- if err != nil {
- _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsFailed", "Error=="+err.Error())})
- return
- }
- if len(inbounds) == 0 {
- _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.noInbounds")})
- return
- }
- var fields []EmbedField
- for _, in := range inbounds {
- state := tr("enabled")
- if !in.Enable {
- state = tr("disabled")
- }
- val := tr("discord.values.inbound",
- "Protocol=="+string(in.Protocol),
- "Port=="+strconv.Itoa(in.Port),
- "Clients=="+strconv.Itoa(len(in.ClientStats)),
- "Up=="+common.FormatTraffic(in.Up),
- "Down=="+common.FormatTraffic(in.Down),
- "State=="+state,
- )
- fields = append(fields, EmbedField{
- Name: fmt.Sprintf("📍 %s", in.Remark),
- Value: val,
- Inline: false,
- })
- }
- embed := Embed{
- Title: tr("discord.commands.inboundsTitle"),
- Description: tr("discord.commands.inboundsDescription", "Count=="+strconv.Itoa(len(inbounds))),
- Color: ColorBlue,
- Timestamp: time.Now().UTC().Format(time.RFC3339),
- Fields: fields,
- Footer: &EmbedFooter{Text: tr("discord.footer")},
- }
- _ = g.discordService.SendEmbed(ctx, embed)
- }
- func (g *GatewayClient) restartXray(ctx context.Context) {
- tr := translator(g.settingService)
- if g.xrayService == nil {
- _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.xrayUnavailable")})
- return
- }
- _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.restarting")})
- if err := g.xrayService.RestartXray(false); err != nil {
- _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.restartFailed", "Error=="+err.Error())})
- } else {
- _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.restartSuccess")})
- }
- }
|