Files
3x-ui/internal/web/service/discord/gateway.go
T
Egor bf7ce2daaa feat(discord): add Discord notification bot service (#6486)
* feat(discord): add Discord notification bot service, settings UI, and event subscriber
- internal/web/service/discord: implement lightweight Discord REST API v10 client and EventBus subscriber
- internal/web/service/setting: add discordBotEnable, discordBotToken, discordChannelId, discordEnabledEvents, discordCpu, discordMemory settings and secret protection
- internal/web/controller: register POST /panel/api/setting/testDiscord endpoint
- frontend: add Discord settings tab, notifications configuration, sidebar navigation, and command palette integration
- translation: add localization keys across all 13 locales
- tests: add comprehensive unit tests with httptest server and verify route/i18n contracts

* fix(discord): address PR review findings on concurrency, linting, i18n, and stories

- subscriber: eliminate unbounded goroutines, sending inline per EventBus contract
- discord: accept context.Context in SendMessage, SendEmbed, SendTest with http.NewRequestWithContext
- format: apply gofumpt to controller and entity struct alignments
- i18n: localize testDiscord controller responses across all 13 locales
- storybook: add DiscordNotifications.stories.tsx component story

* docs: add Discord bot setup and operations guide

- add docs/content/docs/en/operations/discord-bot.mdx with setup steps, event indicators, settings, and troubleshooting
- add docs/content/docs/ru/operations/discord-bot.mdx with localized instructions
- update operations/meta.json across en, ru, zh, fa
- link Discord bot from panel configuration overview

* feat(discord): add discordLang, discordRunTime, discordBotBackup settings and update settings UI

- internal/web/entity: add DiscordRunTime, DiscordBotBackup, DiscordLang fields to AllSetting
- internal/web/service/setting: add defaultValueMap entries, getters, and setters
- frontend: update AllSetting schema, model defaults, and generate OpenAPI / Zod contracts
- frontend: extract shared NotifyTimeField component and update DiscordTab with General and Notifications tabs
- translation: add localization keys across all 13 locales

* feat(discord): implement scheduled status reports and database backup attachments

- internal/web/service/discord: add SendMessageWithFiles supporting multipart uploads
- internal/web/service/discord: implement BuildReport and SendReport generating rich status embeds
- internal/web/service/discord: attach database backup (and config.json) when discordBotBackup is enabled
- internal/web/job: implement DiscordNotifyJob scheduled via robfig/cron
- internal/web/locale: add LocalizerFor and I18nForLang helpers
- internal/web/controller: trigger reloadDiscordFunc to dynamically reschedule cron upon setting changes
- internal/web/web: register and reschedule DiscordNotifyJob
- tests: comprehensive unit tests for multipart uploads, status reporting, and job execution

* feat(discord): add interactive bot commands via Gateway WebSocket and update documentation

- internal/web/service/discord/gateway: connect to Discord Gateway v10 via WebSocket (gorilla/websocket)
- internal/web/service/discord/gateway: handle heartbeat loop, reconnection, and command dispatch
- commands: implement !status, !report, !backup, !usage <email>, !inbounds, !restart, !help (with ! and / prefixes)
- internal/web/web: start/stop Gateway client with server and reload dynamically on setting updates
- docs: update operations guide (en, ru) with scheduled reports, backups, commands, and privileged intents
- tests: add end-to-end WebSocket Gateway test verifying command handling

* style(discord): fix goimports formatting and add 3x-ui to gitignore

* fix(discord): stop gateway panics, reconnect storms and proxy bypass

The Gateway client wrote to its websocket from both the heartbeat ticker
and the read loop answering server-requested op 1 heartbeats. gorilla
panics on concurrent writes and neither goroutine recovers, so a colliding
heartbeat took the whole panel process down; writes now share writeMu.

It also reconnected every 5s forever after close codes Discord marks
non-reconnectable (4004 bad token, 4010-4014, including 4014 when Message
Content Intent is off), re-identifying and logging a warning each time.
The loop now stops on those codes; the docs say to restart the panel.

The gateway dialed with websocket.DefaultDialer, bypassing the panel
egress proxy the REST client already uses, so where Discord is filtered
notifications arrived but commands never connected.

* fix(discord): deliver the scheduled report when the backup upload fails

SendReport posted the report embed and the x-ui.db/config.json attachments
in one multipart request. Once the database outgrows Discord's upload cap
(20 MiB by default) the request is rejected and the report embed is lost
with it on every run, leaving only a log warning. Send the embed first and
the attachments as a second message.

* chore(discord): delete tests that pass whether or not the code works

TestDiscordNotifyJob_NilServiceNoPanic and TestHandleEvent_NilDiscordService
feed a nil DiscordService that web.go never passes, and
TestDiscordNotifyJob_DisabledNoPanic passes with or without the enable
guard because Xray is not running under test.

* fix(discord): require admin user IDs for bot commands and honor discordLang

Any member who could post in the configured channel could run !backup
(the whole x-ui.db and config.json, even with discordBotBackup off),
!restart and !usage. Commands now run only for the Discord user IDs in
the new discordAdminIds setting; an empty list turns commands off.

discordLang was saved and offered in the UI, but nothing read it, so
every embed stayed English. The test message, alerts, the scheduled
report and command replies now render through I18nForLang in the chosen
language, with a discord section in all 13 locales. InitLocalizer takes
an fs.FS so tests load the real translation files.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-13 14:04:53 +02:00

679 lines
18 KiB
Go

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")})
}
}