mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 15:47:14 +00:00
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>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDiscordBaseURL = "https://discord.com/api/v10"
|
||||
discordUserAgent = "DiscordBot (https://github.com/mhsanaei/3x-ui, 3.x)"
|
||||
|
||||
ColorGreen = 0x2ECC71
|
||||
ColorRed = 0xE74C3C
|
||||
ColorOrange = 0xF39C12
|
||||
ColorBlue = 0x3498DB
|
||||
)
|
||||
|
||||
// FileAttachment represents a file attachment to be uploaded with a Discord message.
|
||||
type FileAttachment struct {
|
||||
Filename string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// MessagePayload represents the Discord create message payload.
|
||||
type MessagePayload struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
Embeds []Embed `json:"embeds,omitempty"`
|
||||
}
|
||||
|
||||
// Embed represents a Discord embed object.
|
||||
type Embed struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Color int `json:"color,omitempty"`
|
||||
Fields []EmbedField `json:"fields,omitempty"`
|
||||
Footer *EmbedFooter `json:"footer,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedField represents a field in a Discord embed.
|
||||
type EmbedField struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Inline bool `json:"inline,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedFooter represents a footer in a Discord embed.
|
||||
type EmbedFooter struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// DiscordService manages communication with the Discord API.
|
||||
type DiscordService struct {
|
||||
settingService service.SettingService
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewDiscordService creates a new DiscordService.
|
||||
func NewDiscordService(settingService service.SettingService) *DiscordService {
|
||||
return &DiscordService{
|
||||
settingService: settingService,
|
||||
baseURL: defaultDiscordBaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// SetHTTPClient sets a custom HTTP client (useful for unit testing).
|
||||
func (s *DiscordService) SetHTTPClient(client *http.Client) {
|
||||
s.httpClient = client
|
||||
}
|
||||
|
||||
// SetBaseURL sets a custom base URL for the Discord API (useful for testing with httptest).
|
||||
func (s *DiscordService) SetBaseURL(url string) {
|
||||
s.baseURL = strings.TrimRight(url, "/")
|
||||
}
|
||||
|
||||
func (s *DiscordService) getClient() *http.Client {
|
||||
if s.httpClient != nil {
|
||||
return s.httpClient
|
||||
}
|
||||
return s.settingService.NewProxiedHTTPClient(10 * time.Second)
|
||||
}
|
||||
|
||||
func (s *DiscordService) getBaseURL() string {
|
||||
if s.baseURL != "" {
|
||||
return s.baseURL
|
||||
}
|
||||
return defaultDiscordBaseURL
|
||||
}
|
||||
|
||||
func (s *DiscordService) authCredentials() (token string, channelID string, err error) {
|
||||
rawToken, err := s.settingService.GetDiscordBotToken()
|
||||
if err != nil || strings.TrimSpace(rawToken) == "" {
|
||||
return "", "", errors.New("discord bot token is not configured")
|
||||
}
|
||||
rawChannel, err := s.settingService.GetDiscordChannelId()
|
||||
if err != nil || strings.TrimSpace(rawChannel) == "" {
|
||||
return "", "", errors.New("discord channel id is not configured")
|
||||
}
|
||||
|
||||
cleanToken := strings.TrimSpace(rawToken)
|
||||
cleanToken = strings.TrimPrefix(cleanToken, "Bot ")
|
||||
cleanToken = strings.TrimSpace(cleanToken)
|
||||
if cleanToken == "" {
|
||||
return "", "", errors.New("discord bot token is not configured")
|
||||
}
|
||||
return cleanToken, strings.TrimSpace(rawChannel), nil
|
||||
}
|
||||
|
||||
func parseDiscordResponse(resp *http.Response) error {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
bodyStr := string(respBody)
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK, http.StatusCreated, http.StatusNoContent:
|
||||
return nil
|
||||
case http.StatusBadRequest:
|
||||
return fmt.Errorf("discord bad request (400): %s", bodyStr)
|
||||
case http.StatusUnauthorized:
|
||||
return errors.New("discord unauthorized (401): invalid bot token")
|
||||
case http.StatusForbidden:
|
||||
return errors.New("discord forbidden (403): bot lacks permissions for channel")
|
||||
case http.StatusNotFound:
|
||||
return errors.New("discord not found (404): channel not found")
|
||||
case http.StatusTooManyRequests:
|
||||
return fmt.Errorf("discord rate limited (429): %s", bodyStr)
|
||||
default:
|
||||
return fmt.Errorf("discord API error (%d): %s", resp.StatusCode, bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage sends a Discord message payload to the configured channel.
|
||||
func (s *DiscordService) SendMessage(ctx context.Context, payload MessagePayload) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
cleanToken, channelID, err := s.authCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal discord payload: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/channels/%s/messages", s.getBaseURL(), channelID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create discord request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bot "+cleanToken)
|
||||
req.Header.Set("User-Agent", discordUserAgent)
|
||||
|
||||
resp, err := s.getClient().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return parseDiscordResponse(resp)
|
||||
}
|
||||
|
||||
// SendMessageWithFiles sends a Discord message payload with optional file attachments using multipart/form-data.
|
||||
func (s *DiscordService) SendMessageWithFiles(ctx context.Context, payload MessagePayload, files ...FileAttachment) error {
|
||||
if len(files) == 0 {
|
||||
return s.SendMessage(ctx, payload)
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
cleanToken, channelID, err := s.authCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal discord payload: %w", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadBytes)); err != nil {
|
||||
return fmt.Errorf("write payload_json: %w", err)
|
||||
}
|
||||
|
||||
for i, file := range files {
|
||||
part, err := writer.CreateFormFile(fmt.Sprintf("files[%d]", i), file.Filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create form file part %d: %w", i, err)
|
||||
}
|
||||
if _, err := part.Write(file.Data); err != nil {
|
||||
return fmt.Errorf("write form file part %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return fmt.Errorf("close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/channels/%s/messages", s.getBaseURL(), channelID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create discord request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bot "+cleanToken)
|
||||
req.Header.Set("User-Agent", discordUserAgent)
|
||||
|
||||
resp, err := s.getClient().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return parseDiscordResponse(resp)
|
||||
}
|
||||
|
||||
// SendEmbed is a helper to send an embed payload.
|
||||
func (s *DiscordService) SendEmbed(ctx context.Context, embed Embed) error {
|
||||
return s.SendMessage(ctx, MessagePayload{
|
||||
Embeds: []Embed{embed},
|
||||
})
|
||||
}
|
||||
|
||||
// translator renders messages in the configured Discord bot language, read once per message.
|
||||
func translator(settingService service.SettingService) func(key string, params ...string) string {
|
||||
lang, err := settingService.GetDiscordLang()
|
||||
if err != nil || lang == "" {
|
||||
lang = "en-US"
|
||||
}
|
||||
return func(key string, params ...string) string {
|
||||
return locale.I18nForLang(lang, key, params...)
|
||||
}
|
||||
}
|
||||
|
||||
// SendTest sends a test embed to verify Discord bot configuration.
|
||||
func (s *DiscordService) SendTest(ctx context.Context) error {
|
||||
tr := translator(s.settingService)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "3x-ui"
|
||||
}
|
||||
embed := Embed{
|
||||
Title: tr("discord.test.title"),
|
||||
Description: tr("discord.test.body"),
|
||||
Color: ColorGreen,
|
||||
Timestamp: now,
|
||||
Fields: []EmbedField{
|
||||
{Name: tr("host"), Value: hostname, Inline: true},
|
||||
},
|
||||
Footer: &EmbedFooter{
|
||||
Text: tr("discord.footer"),
|
||||
},
|
||||
}
|
||||
return s.SendEmbed(ctx, embed)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) service.SettingService {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
|
||||
if err := database.InitDB(dbPath); err != nil {
|
||||
t.Fatalf("init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
return service.SettingService{}
|
||||
}
|
||||
|
||||
func TestSendMessage_Success(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
if err := settingService.SetDiscordBotToken("test-bot-token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := settingService.SetDiscordChannelId("123456789012345678"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var reqMethod, reqPath, reqAuth, reqUA, reqCT string
|
||||
var reqPayload MessagePayload
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reqMethod = r.Method
|
||||
reqPath = r.URL.Path
|
||||
reqAuth = r.Header.Get("Authorization")
|
||||
reqUA = r.Header.Get("User-Agent")
|
||||
reqCT = r.Header.Get("Content-Type")
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &reqPayload)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id": "msg-123"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
payload := MessagePayload{
|
||||
Content: "Hello Discord!",
|
||||
Embeds: []Embed{
|
||||
{
|
||||
Title: "Test Embed",
|
||||
Description: "Desc",
|
||||
Color: ColorGreen,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := svc.SendMessage(context.Background(), payload); err != nil {
|
||||
t.Fatalf("SendMessage failed: %v", err)
|
||||
}
|
||||
|
||||
if reqMethod != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", reqMethod)
|
||||
}
|
||||
expectedPath := "/channels/123456789012345678/messages"
|
||||
if reqPath != expectedPath {
|
||||
t.Errorf("expected path %s, got %s", expectedPath, reqPath)
|
||||
}
|
||||
if reqAuth != "Bot test-bot-token" {
|
||||
t.Errorf("expected auth 'Bot test-bot-token', got %s", reqAuth)
|
||||
}
|
||||
if reqUA != discordUserAgent {
|
||||
t.Errorf("expected User-Agent %s, got %s", discordUserAgent, reqUA)
|
||||
}
|
||||
if reqCT != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %s", reqCT)
|
||||
}
|
||||
if reqPayload.Content != "Hello Discord!" || len(reqPayload.Embeds) != 1 {
|
||||
t.Errorf("payload mismatch: %+v", reqPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_CreatedStatus(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("token")
|
||||
_ = settingService.SetDiscordChannelId("ch-1")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
if err := svc.SendEmbed(context.Background(), Embed{Title: "Title"}); err != nil {
|
||||
t.Fatalf("SendEmbed failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_StatusCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
respBody string
|
||||
wantErrSub string
|
||||
}{
|
||||
{"Bad Request", http.StatusBadRequest, `{"message": "Invalid Form Body"}`, "discord bad request (400)"},
|
||||
{"Unauthorized", http.StatusUnauthorized, `{"message": "401: Unauthorized"}`, "discord unauthorized (401)"},
|
||||
{"Forbidden", http.StatusForbidden, `{"message": "Missing Permissions"}`, "discord forbidden (403)"},
|
||||
{"NotFound", http.StatusNotFound, `{"message": "Unknown Channel"}`, "discord not found (404)"},
|
||||
{"RateLimited", http.StatusTooManyRequests, `{"retry_after": 1.5}`, "discord rate limited (429)"},
|
||||
{"InternalError", http.StatusInternalServerError, `{"message": "Server Error"}`, "discord API error (500)"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("token")
|
||||
_ = settingService.SetDiscordChannelId("ch-1")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.statusCode)
|
||||
_, _ = w.Write([]byte(tc.respBody))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
err := svc.SendEmbed(context.Background(), Embed{Title: "Test"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for status %d, got nil", tc.statusCode)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErrSub) {
|
||||
t.Errorf("expected error containing %q, got %q", tc.wantErrSub, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_MissingConfig(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
svc := NewDiscordService(settingService)
|
||||
|
||||
// Both empty
|
||||
err := svc.SendMessage(context.Background(), MessagePayload{Content: "Hi"})
|
||||
if err == nil || !strings.Contains(err.Error(), "token is not configured") {
|
||||
t.Fatalf("expected token not configured error, got %v", err)
|
||||
}
|
||||
|
||||
// Token set, channel empty
|
||||
_ = settingService.SetDiscordBotToken("some-token")
|
||||
err = svc.SendMessage(context.Background(), MessagePayload{Content: "Hi"})
|
||||
if err == nil || !strings.Contains(err.Error(), "channel id is not configured") {
|
||||
t.Fatalf("expected channel id not configured error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTest(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("test-bot-token")
|
||||
_ = settingService.SetDiscordChannelId("999888777")
|
||||
|
||||
var receivedPayload MessagePayload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &receivedPayload)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
if err := svc.SendTest(context.Background()); err != nil {
|
||||
t.Fatalf("SendTest failed: %v", err)
|
||||
}
|
||||
|
||||
if len(receivedPayload.Embeds) != 1 {
|
||||
t.Fatalf("expected 1 embed, got %d", len(receivedPayload.Embeds))
|
||||
}
|
||||
|
||||
embed := receivedPayload.Embeds[0]
|
||||
if embed.Color != ColorGreen {
|
||||
t.Errorf("expected ColorGreen (0x%X), got 0x%X", ColorGreen, embed.Color)
|
||||
}
|
||||
if embed.Timestamp == "" {
|
||||
t.Error("expected non-empty timestamp")
|
||||
} else {
|
||||
parsed, err := time.Parse(time.RFC3339, embed.Timestamp)
|
||||
if err != nil {
|
||||
t.Errorf("timestamp is not RFC3339: %v", err)
|
||||
}
|
||||
if parsed.Location() != time.UTC {
|
||||
t.Errorf("expected UTC timestamp location, got %v", parsed.Location())
|
||||
}
|
||||
}
|
||||
if len(embed.Fields) == 0 {
|
||||
t.Error("expected test embed to have fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_BotPrefixHandling(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("Bot prefixed-token")
|
||||
_ = settingService.SetDiscordChannelId("ch-100")
|
||||
|
||||
var receivedAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
if err := svc.SendEmbed(context.Background(), Embed{Title: "Prefix Test"}); err != nil {
|
||||
t.Fatalf("SendEmbed failed: %v", err)
|
||||
}
|
||||
if receivedAuth != "Bot prefixed-token" {
|
||||
t.Errorf("expected 'Bot prefixed-token', got %q", receivedAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_NoContentStatus(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("token")
|
||||
_ = settingService.SetDiscordChannelId("ch-204")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
if err := svc.SendEmbed(context.Background(), Embed{Title: "204 Test"}); err != nil {
|
||||
t.Fatalf("SendEmbed failed for 204: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_ContextCancelled(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("token")
|
||||
_ = settingService.SetDiscordChannelId("ch-1")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := svc.SendMessage(ctx, MessagePayload{Content: "Cancelled"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error with cancelled context, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageWithFiles_Success(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("test-bot-token")
|
||||
_ = settingService.SetDiscordChannelId("ch-multipart")
|
||||
|
||||
var receivedCT string
|
||||
var receivedPayload MessagePayload
|
||||
receivedFiles := make(map[string][]byte)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedCT = r.Header.Get("Content-Type")
|
||||
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
t.Fatalf("MultipartReader error: %v", err)
|
||||
}
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NextPart error: %v", err)
|
||||
}
|
||||
data, _ := io.ReadAll(part)
|
||||
formName := part.FormName()
|
||||
if formName == "payload_json" {
|
||||
_ = json.Unmarshal(data, &receivedPayload)
|
||||
} else {
|
||||
receivedFiles[part.FileName()] = data
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id": "msg-files"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
payload := MessagePayload{
|
||||
Content: "Report message",
|
||||
Embeds: []Embed{{Title: "Report Embed"}},
|
||||
}
|
||||
files := []FileAttachment{
|
||||
{Filename: "x-ui.db", Data: []byte("sqlite-db-binary")},
|
||||
{Filename: "config.json", Data: []byte(`{"log":{}}`)},
|
||||
}
|
||||
|
||||
if err := svc.SendMessageWithFiles(context.Background(), payload, files...); err != nil {
|
||||
t.Fatalf("SendMessageWithFiles failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(receivedCT, "multipart/form-data; boundary=") {
|
||||
t.Errorf("expected multipart/form-data content type, got %s", receivedCT)
|
||||
}
|
||||
if receivedPayload.Content != "Report message" || len(receivedPayload.Embeds) != 1 {
|
||||
t.Errorf("payload mismatch: %+v", receivedPayload)
|
||||
}
|
||||
if string(receivedFiles["x-ui.db"]) != "sqlite-db-binary" {
|
||||
t.Errorf("x-ui.db mismatch: %s", string(receivedFiles["x-ui.db"]))
|
||||
}
|
||||
if string(receivedFiles["config.json"]) != `{"log":{}}` {
|
||||
t.Errorf("config.json mismatch: %s", string(receivedFiles["config.json"]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
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")})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
type mockXrayRestart struct {
|
||||
restarted bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockXrayRestart) RestartXray(force bool) error {
|
||||
m.restarted = true
|
||||
return m.err
|
||||
}
|
||||
|
||||
func TestGatewayClient_EndToEndCommands(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotEnable(true)
|
||||
_ = settingService.SetDiscordBotToken("test-gw-token")
|
||||
_ = settingService.SetDiscordChannelId("ch-12345")
|
||||
_ = settingService.SetDiscordAdminIds("u1")
|
||||
|
||||
var sentMessages []MessagePayload
|
||||
var mu sync.Mutex
|
||||
|
||||
restServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var p MessagePayload
|
||||
_ = json.NewDecoder(r.Body).Decode(&p)
|
||||
mu.Lock()
|
||||
sentMessages = append(sentMessages, p)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id": "msg-sent"}`))
|
||||
}))
|
||||
defer restServer.Close()
|
||||
|
||||
discordSvc := NewDiscordService(settingService)
|
||||
discordSvc.SetBaseURL(restServer.URL)
|
||||
discordSvc.SetHTTPClient(restServer.Client())
|
||||
|
||||
upgrader := websocket.Upgrader{}
|
||||
wsConnected := make(chan struct{})
|
||||
|
||||
wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 1. Send Op 10 Hello
|
||||
hello := GatewayPayload{
|
||||
Op: opHello,
|
||||
D: []byte(`{"heartbeat_interval": 500}`),
|
||||
}
|
||||
_ = conn.WriteJSON(hello)
|
||||
|
||||
// 2. Read Op 2 Identify
|
||||
var ident GatewayPayload
|
||||
_ = conn.ReadJSON(&ident)
|
||||
|
||||
close(wsConnected)
|
||||
|
||||
// 3. Send !help message
|
||||
helpMsg := MessageCreateData{
|
||||
ID: "m1",
|
||||
ChannelID: "ch-12345",
|
||||
Content: "!help",
|
||||
Author: struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Bot bool `json:"bot"`
|
||||
}{ID: "u1", Username: "Alice", Bot: false},
|
||||
}
|
||||
helpBytes, _ := json.Marshal(helpMsg)
|
||||
_ = conn.WriteJSON(GatewayPayload{
|
||||
Op: opDispatch,
|
||||
T: "MESSAGE_CREATE",
|
||||
D: helpBytes,
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// 4. Send !status message
|
||||
statusMsg := MessageCreateData{
|
||||
ID: "m2",
|
||||
ChannelID: "ch-12345",
|
||||
Content: "!status",
|
||||
Author: struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Bot bool `json:"bot"`
|
||||
}{ID: "u1", Username: "Alice", Bot: false},
|
||||
}
|
||||
statusBytes, _ := json.Marshal(statusMsg)
|
||||
_ = conn.WriteJSON(GatewayPayload{
|
||||
Op: opDispatch,
|
||||
T: "MESSAGE_CREATE",
|
||||
D: statusBytes,
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// 5. Send message from a bot (must be ignored)
|
||||
botMsg := MessageCreateData{
|
||||
ID: "m3",
|
||||
ChannelID: "ch-12345",
|
||||
Content: "!status",
|
||||
Author: struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Bot bool `json:"bot"`
|
||||
}{ID: "u2", Username: "OtherBot", Bot: true},
|
||||
}
|
||||
botBytes, _ := json.Marshal(botMsg)
|
||||
_ = conn.WriteJSON(GatewayPayload{
|
||||
Op: opDispatch,
|
||||
T: "MESSAGE_CREATE",
|
||||
D: botBytes,
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// 6. Send !usage for existing client
|
||||
usageMsg := MessageCreateData{
|
||||
ID: "m4",
|
||||
ChannelID: "ch-12345",
|
||||
Content: "!usage client@test.com",
|
||||
Author: struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Bot bool `json:"bot"`
|
||||
}{ID: "u1", Username: "Alice", Bot: false},
|
||||
}
|
||||
usageBytes, _ := json.Marshal(usageMsg)
|
||||
_ = conn.WriteJSON(GatewayPayload{
|
||||
Op: opDispatch,
|
||||
T: "MESSAGE_CREATE",
|
||||
D: usageBytes,
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// 7. Send !restart command
|
||||
restartMsg := MessageCreateData{
|
||||
ID: "m5",
|
||||
ChannelID: "ch-12345",
|
||||
Content: "!restart",
|
||||
Author: struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Bot bool `json:"bot"`
|
||||
}{ID: "u1", Username: "Alice", Bot: false},
|
||||
}
|
||||
restartBytes, _ := json.Marshal(restartMsg)
|
||||
_ = conn.WriteJSON(GatewayPayload{
|
||||
Op: opDispatch,
|
||||
T: "MESSAGE_CREATE",
|
||||
D: restartBytes,
|
||||
})
|
||||
|
||||
// Keep connection alive until closed
|
||||
for {
|
||||
var p GatewayPayload
|
||||
if err := conn.ReadJSON(&p); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer wsServer.Close()
|
||||
|
||||
mockServer := &mockServerProvider{
|
||||
status: &service.Status{
|
||||
Uptime: 10000,
|
||||
Loads: []float64{0.1, 0.2, 0.3},
|
||||
TcpCount: 5,
|
||||
UdpCount: 2,
|
||||
},
|
||||
}
|
||||
mockInbound := &mockInboundProvider{
|
||||
inbounds: []*model.Inbound{
|
||||
{
|
||||
Id: 1,
|
||||
Remark: "VLESS-Test",
|
||||
Port: 8443,
|
||||
Protocol: "vless",
|
||||
Enable: true,
|
||||
ClientStats: []xray.ClientTraffic{
|
||||
{
|
||||
Email: "client@test.com",
|
||||
Enable: true,
|
||||
Up: 1024,
|
||||
Down: 2048,
|
||||
Total: 10485760,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
mockXray := &mockXrayRestart{}
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(wsServer.URL, "http")
|
||||
|
||||
gw := NewGatewayClient(discordSvc, settingService, mockServer, mockInbound, mockXray)
|
||||
gw.SetGatewayURL(wsURL)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := gw.Start(ctx); err != nil {
|
||||
t.Fatalf("gw.Start failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-wsConnected:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for WS connection")
|
||||
}
|
||||
|
||||
// Wait for dispatches to be processed
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
gw.Stop()
|
||||
|
||||
if gw.IsRunning() {
|
||||
t.Error("expected gateway not to be running after Stop")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
msgs := make([]MessagePayload, len(sentMessages))
|
||||
copy(msgs, sentMessages)
|
||||
mu.Unlock()
|
||||
|
||||
// We expect:
|
||||
// 1. !help response embed
|
||||
// 2. !status response embed
|
||||
// (bot message ignored)
|
||||
// 3. !usage response embed
|
||||
// 4. !restart "Restarting..." and "Restarted successfully"
|
||||
if len(msgs) < 4 {
|
||||
t.Fatalf("expected at least 4 message responses, got %d: %+v", len(msgs), msgs)
|
||||
}
|
||||
|
||||
foundHelp := false
|
||||
foundStatus := false
|
||||
foundUsage := false
|
||||
for _, m := range msgs {
|
||||
for _, e := range m.Embeds {
|
||||
if strings.Contains(e.Title, "Discord Bot Commands") {
|
||||
foundHelp = true
|
||||
}
|
||||
if strings.Contains(e.Title, "Server Status") {
|
||||
foundStatus = true
|
||||
}
|
||||
if strings.Contains(e.Title, "Client Usage: client@test.com") {
|
||||
foundUsage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundHelp {
|
||||
t.Error("expected help embed to be sent")
|
||||
}
|
||||
if !foundStatus {
|
||||
t.Error("expected status embed to be sent")
|
||||
}
|
||||
if !foundUsage {
|
||||
t.Error("expected usage embed to be sent")
|
||||
}
|
||||
if !mockXray.restarted {
|
||||
t.Error("expected Xray core to be restarted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRequestedHeartbeatDoesNotRaceTicker(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotEnable(true)
|
||||
_ = settingService.SetDiscordBotToken("test-gw-token")
|
||||
|
||||
var once sync.Once
|
||||
flooded := make(chan struct{})
|
||||
upgrader := websocket.Upgrader{}
|
||||
wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.WriteJSON(GatewayPayload{Op: opHello, D: []byte(`{"heartbeat_interval": 1}`)})
|
||||
|
||||
readErr := make(chan error, 1)
|
||||
go func() {
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
readErr <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Op 1 from the server makes the read loop write while the 1ms ticker writes too.
|
||||
for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); {
|
||||
if err := conn.WriteJSON(GatewayPayload{Op: opHeartbeat}); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err := <-readErr:
|
||||
t.Errorf("server read a broken client frame during the flood: %v", err)
|
||||
default:
|
||||
}
|
||||
once.Do(func() { close(flooded) })
|
||||
}))
|
||||
defer wsServer.Close()
|
||||
|
||||
gw := NewGatewayClient(NewDiscordService(settingService), settingService, nil, nil, nil)
|
||||
gw.SetGatewayURL("ws" + strings.TrimPrefix(wsServer.URL, "http"))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := gw.Start(ctx); err != nil {
|
||||
t.Fatalf("gw.Start failed: %v", err)
|
||||
}
|
||||
defer gw.Stop()
|
||||
|
||||
select {
|
||||
case <-flooded:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for the heartbeat flood to finish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStopsOnNonReconnectableCloseCode(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotEnable(true)
|
||||
_ = settingService.SetDiscordBotToken("test-gw-token")
|
||||
|
||||
var mu sync.Mutex
|
||||
dials := 0
|
||||
upgrader := websocket.Upgrader{}
|
||||
wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
mu.Lock()
|
||||
dials++
|
||||
mu.Unlock()
|
||||
_ = conn.WriteJSON(GatewayPayload{Op: opHello, D: []byte(`{"heartbeat_interval": 45000}`)})
|
||||
var ident GatewayPayload
|
||||
_ = conn.ReadJSON(&ident)
|
||||
closeMsg := websocket.FormatCloseMessage(4014, "Disallowed intent(s).")
|
||||
_ = conn.WriteControl(websocket.CloseMessage, closeMsg, time.Now().Add(time.Second))
|
||||
}))
|
||||
defer wsServer.Close()
|
||||
|
||||
gw := NewGatewayClient(NewDiscordService(settingService), settingService, nil, nil, nil)
|
||||
gw.SetGatewayURL("ws" + strings.TrimPrefix(wsServer.URL, "http"))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := gw.Start(ctx); err != nil {
|
||||
t.Fatalf("gw.Start failed: %v", err)
|
||||
}
|
||||
defer gw.Stop()
|
||||
|
||||
for deadline := time.Now().Add(2 * time.Second); gw.IsRunning() && time.Now().Before(deadline); {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if gw.IsRunning() {
|
||||
t.Fatal("gateway still running after close code 4014, which Discord marks non-reconnectable")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if dials != 1 {
|
||||
t.Fatalf("gateway dialed %d times, want 1", dials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayDialsThroughPanelEgressProxy(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotEnable(true)
|
||||
_ = settingService.SetDiscordBotToken("test-gw-token")
|
||||
|
||||
identified := make(chan struct{}, 1)
|
||||
upgrader := websocket.Upgrader{}
|
||||
wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.WriteJSON(GatewayPayload{Op: opHello, D: []byte(`{"heartbeat_interval": 45000}`)})
|
||||
var ident GatewayPayload
|
||||
if conn.ReadJSON(&ident) == nil {
|
||||
select {
|
||||
case identified <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer wsServer.Close()
|
||||
|
||||
var mu sync.Mutex
|
||||
tunneledTo := ""
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodConnect {
|
||||
http.Error(w, "CONNECT only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
tunneledTo = r.Host
|
||||
mu.Unlock()
|
||||
upstream, err := net.Dial("tcp", r.Host)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
client, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
_, _ = client.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n"))
|
||||
go func() { _, _ = io.Copy(upstream, client) }()
|
||||
_, _ = io.Copy(client, upstream)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
|
||||
gw := NewGatewayClient(NewDiscordService(settingService), settingService, nil, nil, nil)
|
||||
gw.SetGatewayURL("ws" + strings.TrimPrefix(wsServer.URL, "http"))
|
||||
gw.egressProxyURL = func() string { return proxy.URL }
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := gw.Start(ctx); err != nil {
|
||||
t.Fatalf("gw.Start failed: %v", err)
|
||||
}
|
||||
defer gw.Stop()
|
||||
|
||||
select {
|
||||
case <-identified:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for the gateway to identify")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if want := strings.TrimPrefix(wsServer.URL, "http://"); tunneledTo != want {
|
||||
t.Fatalf("gateway tunneled to %q through the panel egress proxy, want %q", tunneledTo, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayCommandsRequireListedAdmin(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
adminIDs string
|
||||
author string
|
||||
wantRestart bool
|
||||
}{
|
||||
{"listed admin", "111, 222", "222", true},
|
||||
{"unlisted member", "111", "999", false},
|
||||
{"empty list allows nobody", "", "111", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("token")
|
||||
_ = settingService.SetDiscordChannelId("ch-1")
|
||||
_ = settingService.SetDiscordAdminIds(tc.adminIDs)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
restarter := &mockXrayRestart{}
|
||||
msg := MessageCreateData{ChannelID: "ch-1", Content: "!restart"}
|
||||
msg.Author.ID = tc.author
|
||||
NewGatewayClient(svc, settingService, nil, nil, restarter).handleMessage(context.Background(), msg)
|
||||
|
||||
if restarter.restarted != tc.wantRestart {
|
||||
t.Fatalf("author %q with admin list %q: restarted = %v, want %v", tc.author, tc.adminIDs, restarter.restarted, tc.wantRestart)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
||||
)
|
||||
|
||||
type fixedTgLang struct{}
|
||||
|
||||
func (fixedTgLang) GetTgLang() (string, error) { return "en-US", nil }
|
||||
|
||||
// TestMain loads the real translation files so embeds render text instead of bare keys.
|
||||
func TestMain(m *testing.M) {
|
||||
if err := locale.InitLocalizer(os.DirFS("../.."), fixedTgLang{}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestDiscordMessagesFollowDiscordLang(t *testing.T) {
|
||||
const lang = "ru-RU"
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordLang(lang)
|
||||
_ = settingService.SetDiscordBotToken("token")
|
||||
_ = settingService.SetDiscordChannelId("ch-1")
|
||||
_ = settingService.SetDiscordAdminIds("admin-1")
|
||||
|
||||
titles := make(chan string, 4)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var p MessagePayload
|
||||
_ = json.NewDecoder(r.Body).Decode(&p)
|
||||
if len(p.Embeds) > 0 {
|
||||
titles <- p.Embeds[0].Title
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
sentTitle := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
select {
|
||||
case title := <-titles:
|
||||
return title
|
||||
default:
|
||||
t.Fatal("no embed reached Discord")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
key string
|
||||
render func(t *testing.T) string
|
||||
}{
|
||||
{"discord.test.title", func(t *testing.T) string {
|
||||
if err := svc.SendTest(context.Background()); err != nil {
|
||||
t.Fatalf("SendTest: %v", err)
|
||||
}
|
||||
return sentTitle(t)
|
||||
}},
|
||||
{"discord.alerts.xrayCrash", func(t *testing.T) string {
|
||||
embed, _ := NewSubscriber(settingService, svc).FormatEmbed(eventbus.Event{Type: eventbus.EventXrayCrash})
|
||||
return embed.Title
|
||||
}},
|
||||
{"discord.report.title", func(t *testing.T) string {
|
||||
payload, _, err := svc.BuildReport(context.Background(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildReport: %v", err)
|
||||
}
|
||||
return payload.Embeds[0].Title
|
||||
}},
|
||||
{"discord.commands.helpTitle", func(t *testing.T) string {
|
||||
msg := MessageCreateData{ChannelID: "ch-1", Content: "!help"}
|
||||
msg.Author.ID = "admin-1"
|
||||
NewGatewayClient(svc, settingService, nil, nil, nil).handleMessage(context.Background(), msg)
|
||||
return sentTitle(t)
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
want := locale.I18nForLang(lang, tc.key)
|
||||
if want == locale.I18nForLang("en-US", tc.key) {
|
||||
t.Fatalf("%s has no distinct %s translation", tc.key, lang)
|
||||
}
|
||||
if got := tc.render(t); got != want {
|
||||
t.Fatalf("title = %q, want the %s text %q", got, lang, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ServerProvider abstracts server status and database backup operations.
|
||||
type ServerProvider interface {
|
||||
GetStatus(lastStatus *service.Status) *service.Status
|
||||
GetDb() ([]byte, error)
|
||||
BackupFilename(requestHost string) string
|
||||
}
|
||||
|
||||
// InboundProvider abstracts inbound management operations.
|
||||
type InboundProvider interface {
|
||||
GetAllInbounds() ([]*model.Inbound, error)
|
||||
}
|
||||
|
||||
// BuildReport constructs the status report payload and backup attachments.
|
||||
func (s *DiscordService) BuildReport(ctx context.Context, server ServerProvider, inbound InboundProvider) (MessagePayload, []FileAttachment, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "3x-ui"
|
||||
}
|
||||
|
||||
var status *service.Status
|
||||
if server != nil {
|
||||
status = server.GetStatus(nil)
|
||||
}
|
||||
if status == nil {
|
||||
status = &service.Status{
|
||||
Loads: []float64{0, 0, 0},
|
||||
}
|
||||
status.Xray.State = service.ProcessState("unknown")
|
||||
status.Xray.Version = "unknown"
|
||||
}
|
||||
|
||||
var onlines []string
|
||||
if process := service.XrayProcess(); process != nil && process.IsRunning() {
|
||||
onlines = process.GetOnlineClients()
|
||||
}
|
||||
|
||||
trDiff := int64(0)
|
||||
exDiff := int64(0)
|
||||
now := time.Now().Unix() * 1000
|
||||
|
||||
trafficThreshold, err := s.settingService.GetTrafficDiff()
|
||||
if err == nil && trafficThreshold > 0 {
|
||||
trDiff = int64(trafficThreshold) * 1073741824
|
||||
}
|
||||
expireThreshold, err := s.settingService.GetExpireDiff()
|
||||
if err == nil && expireThreshold > 0 {
|
||||
exDiff = int64(expireThreshold) * 86400000
|
||||
}
|
||||
|
||||
var totalInbounds, disabledInbounds, exhaustedInbounds int
|
||||
var totalClients, disabledClients, exhaustedClients int
|
||||
seenClients := make(map[string]bool)
|
||||
|
||||
if inbound != nil {
|
||||
inbounds, err := inbound.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Discord report: unable to load inbounds: ", err)
|
||||
} else {
|
||||
totalInbounds = len(inbounds)
|
||||
for _, in := range inbounds {
|
||||
if !in.Enable {
|
||||
disabledInbounds++
|
||||
} else if (in.ExpiryTime > 0 && (in.ExpiryTime-now < exDiff)) ||
|
||||
(in.Total > 0 && (in.Total-(in.Up+in.Down) < trDiff)) {
|
||||
exhaustedInbounds++
|
||||
}
|
||||
|
||||
for _, client := range in.ClientStats {
|
||||
if seenClients[client.Email] {
|
||||
continue
|
||||
}
|
||||
seenClients[client.Email] = true
|
||||
totalClients++
|
||||
if !client.Enable {
|
||||
disabledClients++
|
||||
} else if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) ||
|
||||
(client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) {
|
||||
exhaustedClients++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tr := translator(s.settingService)
|
||||
days := status.Uptime / 86400
|
||||
hours := (status.Uptime % 86400) / 3600
|
||||
uptimeStr := tr("discord.values.uptime", "Days=="+fmt.Sprint(days), "Hours=="+fmt.Sprint(hours))
|
||||
|
||||
ramStr := fmt.Sprintf("%s / %s", common.FormatTraffic(int64(status.Mem.Current)), common.FormatTraffic(int64(status.Mem.Total)))
|
||||
trafficStr := tr("discord.values.traffic",
|
||||
"Up=="+common.FormatTraffic(int64(status.NetTraffic.Sent)),
|
||||
"Down=="+common.FormatTraffic(int64(status.NetTraffic.Recv)),
|
||||
"Total=="+common.FormatTraffic(int64(status.NetTraffic.Sent+status.NetTraffic.Recv)),
|
||||
)
|
||||
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]
|
||||
}
|
||||
loadStr := fmt.Sprintf("%.2f, %.2f, %.2f", load1, load2, load3)
|
||||
|
||||
fields := []EmbedField{
|
||||
{Name: tr("host"), Value: hostname, Inline: true},
|
||||
{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: uptimeStr, Inline: true},
|
||||
{Name: tr("discord.fields.systemLoad"), Value: loadStr, Inline: true},
|
||||
{Name: tr("pages.index.memory"), Value: ramStr, Inline: true},
|
||||
{Name: tr("discord.fields.networkTraffic"), Value: trafficStr, Inline: false},
|
||||
{Name: tr("pages.index.historyTabConnections"), Value: fmt.Sprintf("TCP: %d | UDP: %d", status.TcpCount, status.UdpCount), Inline: true},
|
||||
{Name: tr("pages.index.historyTitleOnline"), Value: strconv.Itoa(len(onlines)), Inline: true},
|
||||
{Name: tr("tgbot.inbounds"), Value: tr("discord.values.counts", "Total=="+strconv.Itoa(totalInbounds), "Depleting=="+strconv.Itoa(exhaustedInbounds), "Disabled=="+strconv.Itoa(disabledInbounds)), Inline: false},
|
||||
{Name: tr("clients"), Value: tr("discord.values.counts", "Total=="+strconv.Itoa(totalClients), "Depleting=="+strconv.Itoa(exhaustedClients), "Disabled=="+strconv.Itoa(disabledClients)), Inline: false},
|
||||
}
|
||||
|
||||
ipv4, ipv6 := getInterfaceIPs()
|
||||
if ipv4 != "" {
|
||||
fields = append(fields, EmbedField{Name: "IPv4", Value: ipv4, Inline: true})
|
||||
}
|
||||
if ipv6 != "" {
|
||||
fields = append(fields, EmbedField{Name: "IPv6", Value: ipv6, Inline: true})
|
||||
}
|
||||
|
||||
runTime, _ := s.settingService.GetDiscordRunTime()
|
||||
if runTime == "" {
|
||||
runTime = "@daily"
|
||||
}
|
||||
|
||||
embed := Embed{
|
||||
Title: tr("discord.report.title"),
|
||||
Description: tr("discord.report.summary", "Host=="+hostname),
|
||||
Color: ColorBlue,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Fields: fields,
|
||||
Footer: &EmbedFooter{
|
||||
Text: tr("discord.report.footer", "RunTime=="+runTime),
|
||||
},
|
||||
}
|
||||
|
||||
payload := MessagePayload{
|
||||
Embeds: []Embed{embed},
|
||||
}
|
||||
|
||||
var files []FileAttachment
|
||||
backupEnabled, err := s.settingService.GetDiscordBotBackup()
|
||||
if err == nil && backupEnabled && server != nil {
|
||||
dbData, err := server.GetDb()
|
||||
if err != nil {
|
||||
logger.Warning("Discord report: failed to get DB backup: ", err)
|
||||
} else if len(dbData) > 0 {
|
||||
filename := server.BackupFilename("")
|
||||
if filename == "" {
|
||||
filename = "x-ui.db"
|
||||
}
|
||||
files = append(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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return payload, files, nil
|
||||
}
|
||||
|
||||
// SendReport generates and sends the periodic report to Discord.
|
||||
func (s *DiscordService) SendReport(ctx context.Context, server ServerProvider, inbound InboundProvider) error {
|
||||
payload, files, err := s.BuildReport(ctx, server, inbound)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build discord report: %w", err)
|
||||
}
|
||||
// Separate messages: a backup over Discord's upload cap must not drop the report with it.
|
||||
if err := s.SendMessage(ctx, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.SendMessageWithFiles(ctx, MessagePayload{}, files...)
|
||||
}
|
||||
|
||||
func getInterfaceIPs() (ipv4, ipv6 string) {
|
||||
netInterfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
var v4s, v6s []string
|
||||
for _, iface := range netInterfaces {
|
||||
if (iface.Flags&net.FlagUp) == 0 || (iface.Flags&net.FlagLoopback) != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ipnet, ok := addr.(*net.IPNet)
|
||||
if !ok || ipnet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
if ip := ipnet.IP.To4(); ip != nil {
|
||||
v4s = append(v4s, ip.String())
|
||||
} else if ip := ipnet.IP.To16(); ip != nil && !ipnet.IP.IsLinkLocalUnicast() {
|
||||
v6s = append(v6s, ip.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(v4s, ", "), strings.Join(v6s, ", ")
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
type mockServerProvider struct {
|
||||
status *service.Status
|
||||
dbData []byte
|
||||
dbErr error
|
||||
filename string
|
||||
}
|
||||
|
||||
func (m *mockServerProvider) GetStatus(lastStatus *service.Status) *service.Status {
|
||||
return m.status
|
||||
}
|
||||
|
||||
func (m *mockServerProvider) GetDb() ([]byte, error) {
|
||||
return m.dbData, m.dbErr
|
||||
}
|
||||
|
||||
func (m *mockServerProvider) BackupFilename(requestHost string) string {
|
||||
if m.filename != "" {
|
||||
return m.filename
|
||||
}
|
||||
return "x-ui_test.db"
|
||||
}
|
||||
|
||||
type mockInboundProvider struct {
|
||||
inbounds []*model.Inbound
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockInboundProvider) GetAllInbounds() ([]*model.Inbound, error) {
|
||||
return m.inbounds, m.err
|
||||
}
|
||||
|
||||
func TestBuildReport_NoBackup(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("test-bot-token")
|
||||
_ = settingService.SetDiscordChannelId("12345")
|
||||
_ = settingService.SetDiscordBotBackup(false)
|
||||
_ = settingService.SetDiscordRunTime("@daily")
|
||||
|
||||
mockStatus := &service.Status{
|
||||
Uptime: 172800,
|
||||
Loads: []float64{0.5, 0.4, 0.3},
|
||||
TcpCount: 15,
|
||||
UdpCount: 5,
|
||||
}
|
||||
mockStatus.Xray.State = service.Running
|
||||
mockStatus.Xray.Version = "25.1.0"
|
||||
|
||||
mockServer := &mockServerProvider{
|
||||
status: mockStatus,
|
||||
dbData: []byte("sqlite-backup-bytes"),
|
||||
}
|
||||
|
||||
mockInbound := &mockInboundProvider{
|
||||
inbounds: []*model.Inbound{
|
||||
{
|
||||
Id: 1,
|
||||
Remark: "VLESS-TCP",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
ClientStats: []xray.ClientTraffic{
|
||||
{Email: "user1@example.com", Enable: true, Up: 100, Down: 200},
|
||||
{Email: "user2@example.com", Enable: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
payload, files, err := svc.BuildReport(context.Background(), mockServer, mockInbound)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildReport failed: %v", err)
|
||||
}
|
||||
|
||||
if len(payload.Embeds) != 1 {
|
||||
t.Fatalf("expected 1 embed, got %d", len(payload.Embeds))
|
||||
}
|
||||
embed := payload.Embeds[0]
|
||||
if embed.Color != ColorBlue {
|
||||
t.Errorf("expected ColorBlue, got %X", embed.Color)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("expected 0 files when backup is disabled, got %d", len(files))
|
||||
}
|
||||
|
||||
foundHost, foundUptime, foundXray := false, false, false
|
||||
for _, field := range embed.Fields {
|
||||
if field.Name == "Host" {
|
||||
foundHost = true
|
||||
}
|
||||
if field.Name == "Uptime" && field.Value == "2d 0h" {
|
||||
foundUptime = true
|
||||
}
|
||||
if field.Name == "Xray Core" && field.Value == "25.1.0 (running)" {
|
||||
foundXray = true
|
||||
}
|
||||
}
|
||||
if !foundHost || !foundUptime || !foundXray {
|
||||
t.Errorf("expected fields not found in embed: %+v", embed.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReport_WithBackup(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("test-bot-token")
|
||||
_ = settingService.SetDiscordChannelId("12345")
|
||||
_ = settingService.SetDiscordBotBackup(true)
|
||||
|
||||
mockStatus := &service.Status{
|
||||
Uptime: 3600,
|
||||
}
|
||||
mockStatus.Xray.State = service.Running
|
||||
mockStatus.Xray.Version = "25.1.0"
|
||||
|
||||
mockServer := &mockServerProvider{
|
||||
status: mockStatus,
|
||||
dbData: []byte("test-db-content"),
|
||||
filename: "x-ui_backup.db",
|
||||
}
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
_, files, err := svc.BuildReport(context.Background(), mockServer, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildReport failed: %v", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
t.Fatal("expected at least 1 backup file, got 0")
|
||||
}
|
||||
if files[0].Filename != "x-ui_backup.db" {
|
||||
t.Errorf("expected filename 'x-ui_backup.db', got %q", files[0].Filename)
|
||||
}
|
||||
if string(files[0].Data) != "test-db-content" {
|
||||
t.Errorf("expected db content 'test-db-content', got %q", string(files[0].Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendReport_Integration(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("test-token")
|
||||
_ = settingService.SetDiscordChannelId("998877")
|
||||
_ = settingService.SetDiscordBotBackup(true)
|
||||
|
||||
var receivedRequest bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedRequest = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id": "msg-123"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
mockStatus := &service.Status{
|
||||
Uptime: 86400,
|
||||
}
|
||||
mockStatus.Xray.State = service.Running
|
||||
mockStatus.Xray.Version = "25.1.0"
|
||||
|
||||
mockServer := &mockServerProvider{
|
||||
status: mockStatus,
|
||||
dbData: []byte("sqlite-data"),
|
||||
}
|
||||
|
||||
err := svc.SendReport(context.Background(), mockServer, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SendReport failed: %v", err)
|
||||
}
|
||||
if !receivedRequest {
|
||||
t.Error("expected server to receive report request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendReport_DeliversEmbedWhenBackupUploadIsRejected(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotToken("test-token")
|
||||
_ = settingService.SetDiscordChannelId("998877")
|
||||
_ = settingService.SetDiscordBotBackup(true)
|
||||
|
||||
embeds := make(chan int, 4)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
_, _ = w.Write([]byte(`{"message": "Request entity too large", "code": 40005}`))
|
||||
return
|
||||
}
|
||||
var p MessagePayload
|
||||
_ = json.NewDecoder(r.Body).Decode(&p)
|
||||
embeds <- len(p.Embeds)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewDiscordService(settingService)
|
||||
svc.SetBaseURL(server.URL)
|
||||
svc.SetHTTPClient(server.Client())
|
||||
|
||||
mockServer := &mockServerProvider{
|
||||
status: &service.Status{Uptime: 86400},
|
||||
dbData: []byte("sqlite-data-over-the-upload-cap"),
|
||||
}
|
||||
|
||||
err := svc.SendReport(context.Background(), mockServer, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "(413)") {
|
||||
t.Fatalf("SendReport error = %v, want the rejected backup upload (413)", err)
|
||||
}
|
||||
select {
|
||||
case n := <-embeds:
|
||||
if n != 1 {
|
||||
t.Fatalf("report message carried %d embeds, want 1", n)
|
||||
}
|
||||
default:
|
||||
t.Fatal("report embed was never delivered: it rode on the rejected backup upload")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// Subscriber handles event bus messages and forwards them to Discord.
|
||||
type Subscriber struct {
|
||||
settingService service.SettingService
|
||||
discordService *DiscordService
|
||||
limiter *eventbus.RateLimiter
|
||||
}
|
||||
|
||||
// NewSubscriber creates a new Discord event subscriber.
|
||||
func NewSubscriber(settingService service.SettingService, discordService *DiscordService) *Subscriber {
|
||||
return &Subscriber{
|
||||
settingService: settingService,
|
||||
discordService: discordService,
|
||||
limiter: eventbus.NewRateLimiter(1 * time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleEvent is the eventbus subscriber callback.
|
||||
func (s *Subscriber) HandleEvent(e eventbus.Event) {
|
||||
if s.discordService == nil {
|
||||
return
|
||||
}
|
||||
if on, err := s.settingService.GetDiscordBotEnable(); err != nil || !on {
|
||||
return
|
||||
}
|
||||
if !s.isEventEnabled(e.Type) {
|
||||
return
|
||||
}
|
||||
embed, ok := s.FormatEmbed(e)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e.Type != eventbus.EventLoginAttempt {
|
||||
if !s.limiter.Allow(e.Type, e.Source) {
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.discordService.SendEmbed(ctx, embed); err != nil {
|
||||
logger.Warning("discord subscriber: send failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool {
|
||||
events, err := s.settingService.GetDiscordEnabledEvents()
|
||||
if err != nil || events == "" {
|
||||
return false
|
||||
}
|
||||
for e := range strings.SplitSeq(events, ",") {
|
||||
if strings.TrimSpace(e) == string(t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func truncateRunes(s string, maxRunes int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
if maxRunes <= 3 {
|
||||
return string(r[:maxRunes])
|
||||
}
|
||||
return string(r[:maxRunes-3]) + "..."
|
||||
}
|
||||
|
||||
func cleanField(name, value string, inline bool) EmbedField {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "-"
|
||||
} else {
|
||||
name = truncateRunes(name, 256)
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
value = "-"
|
||||
} else {
|
||||
value = truncateRunes(value, 1024)
|
||||
}
|
||||
return EmbedField{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Inline: inline,
|
||||
}
|
||||
}
|
||||
|
||||
// FormatEmbed converts an eventbus.Event into a Discord Embed.
|
||||
// Returns false if the event should not produce a message (e.g. thresholds not exceeded).
|
||||
func (s *Subscriber) FormatEmbed(e eventbus.Event) (Embed, bool) {
|
||||
h, _ := os.Hostname()
|
||||
if h == "" {
|
||||
h = "unknown"
|
||||
}
|
||||
var ts string
|
||||
if e.Timestamp.IsZero() {
|
||||
ts = time.Now().UTC().Format(time.RFC3339)
|
||||
} else {
|
||||
ts = e.Timestamp.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
footer := &EmbedFooter{
|
||||
Text: truncateRunes("3x-ui • "+h, 2048),
|
||||
}
|
||||
tr := translator(s.settingService)
|
||||
|
||||
switch e.Type {
|
||||
case eventbus.EventOutboundDown:
|
||||
fields := []EmbedField{
|
||||
cleanField(tr("discord.fields.outbound"), e.Source, true),
|
||||
}
|
||||
var data *eventbus.OutboundHealthData
|
||||
switch d := e.Data.(type) {
|
||||
case *eventbus.OutboundHealthData:
|
||||
data = d
|
||||
case eventbus.OutboundHealthData:
|
||||
data = &d
|
||||
}
|
||||
if data != nil {
|
||||
if data.Error != "" {
|
||||
fields = append(fields, cleanField(tr("discord.fields.error"), data.Error, false))
|
||||
}
|
||||
if data.Delay > 0 {
|
||||
fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.Delay), true))
|
||||
}
|
||||
}
|
||||
return Embed{
|
||||
Title: tr("discord.alerts.outboundDown"),
|
||||
Color: ColorRed,
|
||||
Timestamp: ts,
|
||||
Fields: fields,
|
||||
Footer: footer,
|
||||
}, true
|
||||
|
||||
case eventbus.EventOutboundUp:
|
||||
fields := []EmbedField{
|
||||
cleanField(tr("discord.fields.outbound"), e.Source, true),
|
||||
}
|
||||
var data *eventbus.OutboundHealthData
|
||||
switch d := e.Data.(type) {
|
||||
case *eventbus.OutboundHealthData:
|
||||
data = d
|
||||
case eventbus.OutboundHealthData:
|
||||
data = &d
|
||||
}
|
||||
if data != nil && data.Delay > 0 {
|
||||
fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.Delay), true))
|
||||
}
|
||||
return Embed{
|
||||
Title: tr("discord.alerts.outboundUp"),
|
||||
Color: ColorGreen,
|
||||
Timestamp: ts,
|
||||
Fields: fields,
|
||||
Footer: footer,
|
||||
}, true
|
||||
|
||||
case eventbus.EventNodeDown:
|
||||
fields := []EmbedField{
|
||||
cleanField(tr("discord.fields.node"), e.Source, true),
|
||||
}
|
||||
var data *eventbus.NodeHealthData
|
||||
switch d := e.Data.(type) {
|
||||
case *eventbus.NodeHealthData:
|
||||
data = d
|
||||
case eventbus.NodeHealthData:
|
||||
data = &d
|
||||
}
|
||||
if data != nil && data.XrayError != "" {
|
||||
fields = append(fields, cleanField(tr("discord.fields.error"), data.XrayError, false))
|
||||
}
|
||||
return Embed{
|
||||
Title: tr("discord.alerts.nodeDown"),
|
||||
Color: ColorRed,
|
||||
Timestamp: ts,
|
||||
Fields: fields,
|
||||
Footer: footer,
|
||||
}, true
|
||||
|
||||
case eventbus.EventNodeUp:
|
||||
fields := []EmbedField{
|
||||
cleanField(tr("discord.fields.node"), e.Source, true),
|
||||
}
|
||||
var data *eventbus.NodeHealthData
|
||||
switch d := e.Data.(type) {
|
||||
case *eventbus.NodeHealthData:
|
||||
data = d
|
||||
case eventbus.NodeHealthData:
|
||||
data = &d
|
||||
}
|
||||
if data != nil && data.LatencyMs > 0 {
|
||||
fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.LatencyMs), true))
|
||||
}
|
||||
return Embed{
|
||||
Title: tr("discord.alerts.nodeUp"),
|
||||
Color: ColorGreen,
|
||||
Timestamp: ts,
|
||||
Fields: fields,
|
||||
Footer: footer,
|
||||
}, true
|
||||
|
||||
case eventbus.EventXrayCrash:
|
||||
var fields []EmbedField
|
||||
if e.Data != nil {
|
||||
fields = append(fields, cleanField(tr("discord.fields.error"), fmt.Sprint(e.Data), false))
|
||||
}
|
||||
return Embed{
|
||||
Title: tr("discord.alerts.xrayCrash"),
|
||||
Color: ColorRed,
|
||||
Timestamp: ts,
|
||||
Fields: fields,
|
||||
Footer: footer,
|
||||
}, true
|
||||
|
||||
case eventbus.EventCPUHigh:
|
||||
var data *eventbus.SystemMetricData
|
||||
switch d := e.Data.(type) {
|
||||
case *eventbus.SystemMetricData:
|
||||
data = d
|
||||
case eventbus.SystemMetricData:
|
||||
data = &d
|
||||
}
|
||||
if data != nil {
|
||||
discordCpu, err := s.settingService.GetDiscordCpu()
|
||||
if err != nil || discordCpu <= 0 || data.Percent <= float64(discordCpu) {
|
||||
return Embed{}, false
|
||||
}
|
||||
fields := []EmbedField{
|
||||
cleanField(tr("usage"), fmt.Sprintf("%.2f%%", data.Percent), true),
|
||||
cleanField(tr("discord.fields.threshold"), fmt.Sprintf("%d%%", discordCpu), true),
|
||||
}
|
||||
return Embed{
|
||||
Title: tr("discord.alerts.cpuHigh"),
|
||||
Color: ColorOrange,
|
||||
Timestamp: ts,
|
||||
Fields: fields,
|
||||
Footer: footer,
|
||||
}, true
|
||||
}
|
||||
return Embed{}, false
|
||||
|
||||
case eventbus.EventMemoryHigh:
|
||||
var data *eventbus.SystemMetricData
|
||||
switch d := e.Data.(type) {
|
||||
case *eventbus.SystemMetricData:
|
||||
data = d
|
||||
case eventbus.SystemMetricData:
|
||||
data = &d
|
||||
}
|
||||
if data != nil {
|
||||
discordMem, err := s.settingService.GetDiscordMemory()
|
||||
if err != nil || discordMem <= 0 || data.Percent <= float64(discordMem) {
|
||||
return Embed{}, false
|
||||
}
|
||||
fields := []EmbedField{
|
||||
cleanField(tr("usage"), fmt.Sprintf("%.2f%%", data.Percent), true),
|
||||
cleanField(tr("discord.fields.threshold"), fmt.Sprintf("%d%%", discordMem), true),
|
||||
}
|
||||
return Embed{
|
||||
Title: tr("discord.alerts.memoryHigh"),
|
||||
Color: ColorOrange,
|
||||
Timestamp: ts,
|
||||
Fields: fields,
|
||||
Footer: footer,
|
||||
}, true
|
||||
}
|
||||
return Embed{}, false
|
||||
|
||||
case eventbus.EventLoginAttempt:
|
||||
var data *eventbus.LoginEventData
|
||||
switch d := e.Data.(type) {
|
||||
case *eventbus.LoginEventData:
|
||||
data = d
|
||||
case eventbus.LoginEventData:
|
||||
data = &d
|
||||
}
|
||||
if data != nil {
|
||||
if data.Status == "success" {
|
||||
fields := []EmbedField{
|
||||
cleanField(tr("username"), data.Username, true),
|
||||
cleanField("IP", data.IP, true),
|
||||
}
|
||||
if data.Time != "" {
|
||||
fields = append(fields, cleanField(tr("discord.fields.time"), data.Time, true))
|
||||
}
|
||||
return Embed{
|
||||
Title: tr("discord.alerts.loginSuccess"),
|
||||
Color: ColorGreen,
|
||||
Timestamp: ts,
|
||||
Fields: fields,
|
||||
Footer: footer,
|
||||
}, true
|
||||
}
|
||||
fields := []EmbedField{
|
||||
cleanField(tr("username"), data.Username, true),
|
||||
cleanField("IP", data.IP, true),
|
||||
}
|
||||
if data.Reason != "" {
|
||||
fields = append(fields, cleanField(tr("discord.fields.reason"), data.Reason, false))
|
||||
}
|
||||
if data.Time != "" {
|
||||
fields = append(fields, cleanField(tr("discord.fields.time"), data.Time, true))
|
||||
}
|
||||
return Embed{
|
||||
Title: tr("discord.alerts.loginFailed"),
|
||||
Color: ColorRed,
|
||||
Timestamp: ts,
|
||||
Fields: fields,
|
||||
Footer: footer,
|
||||
}, true
|
||||
}
|
||||
fields := []EmbedField{
|
||||
cleanField(tr("discord.fields.source"), e.Source, true),
|
||||
}
|
||||
return Embed{
|
||||
Title: tr("discord.alerts.loginFailed"),
|
||||
Color: ColorRed,
|
||||
Timestamp: ts,
|
||||
Fields: fields,
|
||||
Footer: footer,
|
||||
}, true
|
||||
}
|
||||
|
||||
return Embed{}, false
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
)
|
||||
|
||||
func TestFormatEmbed_OutboundDownAndUp(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
discordService := NewDiscordService(settingService)
|
||||
sub := NewSubscriber(settingService, discordService)
|
||||
|
||||
now := time.Date(2026, 9, 12, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Outbound down
|
||||
downEvent := eventbus.Event{
|
||||
Type: eventbus.EventOutboundDown,
|
||||
Source: "proxy-1",
|
||||
Timestamp: now,
|
||||
Data: &eventbus.OutboundHealthData{
|
||||
Delay: 500,
|
||||
Error: "timeout connecting",
|
||||
},
|
||||
}
|
||||
embed, ok := sub.FormatEmbed(downEvent)
|
||||
if !ok {
|
||||
t.Fatal("expected embed to be formatted")
|
||||
}
|
||||
if embed.Color != ColorRed {
|
||||
t.Errorf("expected ColorRed, got 0x%X", embed.Color)
|
||||
}
|
||||
if embed.Timestamp != "2026-09-12T12:00:00Z" {
|
||||
t.Errorf("expected RFC3339 UTC timestamp, got %s", embed.Timestamp)
|
||||
}
|
||||
if len(embed.Fields) != 3 {
|
||||
t.Fatalf("expected 3 fields, got %d", len(embed.Fields))
|
||||
}
|
||||
|
||||
// Outbound up
|
||||
upEvent := eventbus.Event{
|
||||
Type: eventbus.EventOutboundUp,
|
||||
Source: "proxy-1",
|
||||
Timestamp: now,
|
||||
Data: &eventbus.OutboundHealthData{
|
||||
Delay: 120,
|
||||
},
|
||||
}
|
||||
embedUp, ok := sub.FormatEmbed(upEvent)
|
||||
if !ok {
|
||||
t.Fatal("expected embed to be formatted")
|
||||
}
|
||||
if embedUp.Color != ColorGreen {
|
||||
t.Errorf("expected ColorGreen, got 0x%X", embedUp.Color)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatEmbed_NodeDownAndUp(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
discordService := NewDiscordService(settingService)
|
||||
sub := NewSubscriber(settingService, discordService)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Node down
|
||||
downEvent := eventbus.Event{
|
||||
Type: eventbus.EventNodeDown,
|
||||
Source: "node-us",
|
||||
Timestamp: now,
|
||||
Data: &eventbus.NodeHealthData{
|
||||
XrayError: "connection refused",
|
||||
},
|
||||
}
|
||||
embed, ok := sub.FormatEmbed(downEvent)
|
||||
if !ok {
|
||||
t.Fatal("expected embed to be formatted")
|
||||
}
|
||||
if embed.Color != ColorRed {
|
||||
t.Errorf("expected ColorRed, got 0x%X", embed.Color)
|
||||
}
|
||||
|
||||
// Node up
|
||||
upEvent := eventbus.Event{
|
||||
Type: eventbus.EventNodeUp,
|
||||
Source: "node-us",
|
||||
Timestamp: now,
|
||||
Data: &eventbus.NodeHealthData{
|
||||
LatencyMs: 45,
|
||||
},
|
||||
}
|
||||
embedUp, ok := sub.FormatEmbed(upEvent)
|
||||
if !ok {
|
||||
t.Fatal("expected embed to be formatted")
|
||||
}
|
||||
if embedUp.Color != ColorGreen {
|
||||
t.Errorf("expected ColorGreen, got 0x%X", embedUp.Color)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatEmbed_XrayCrash(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
discordService := NewDiscordService(settingService)
|
||||
sub := NewSubscriber(settingService, discordService)
|
||||
|
||||
crashEvent := eventbus.Event{
|
||||
Type: eventbus.EventXrayCrash,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Data: "panic: core dump",
|
||||
}
|
||||
embed, ok := sub.FormatEmbed(crashEvent)
|
||||
if !ok {
|
||||
t.Fatal("expected embed to be formatted")
|
||||
}
|
||||
if embed.Color != ColorRed {
|
||||
t.Errorf("expected ColorRed, got 0x%X", embed.Color)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatEmbed_CpuAndMemoryThresholds(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordCpu(80)
|
||||
_ = settingService.SetDiscordMemory(75)
|
||||
|
||||
discordService := NewDiscordService(settingService)
|
||||
sub := NewSubscriber(settingService, discordService)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// CPU below threshold -> no embed
|
||||
_, ok := sub.FormatEmbed(eventbus.Event{
|
||||
Type: eventbus.EventCPUHigh,
|
||||
Timestamp: now,
|
||||
Data: &eventbus.SystemMetricData{Percent: 79.5},
|
||||
})
|
||||
if ok {
|
||||
t.Error("expected no embed when CPU is below threshold")
|
||||
}
|
||||
|
||||
// CPU above threshold -> Orange embed
|
||||
embedCpu, ok := sub.FormatEmbed(eventbus.Event{
|
||||
Type: eventbus.EventCPUHigh,
|
||||
Timestamp: now,
|
||||
Data: &eventbus.SystemMetricData{Percent: 85.2},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected embed when CPU is above threshold")
|
||||
}
|
||||
if embedCpu.Color != ColorOrange {
|
||||
t.Errorf("expected ColorOrange (0x%X), got 0x%X", ColorOrange, embedCpu.Color)
|
||||
}
|
||||
|
||||
// Memory below threshold -> no embed
|
||||
_, ok = sub.FormatEmbed(eventbus.Event{
|
||||
Type: eventbus.EventMemoryHigh,
|
||||
Timestamp: now,
|
||||
Data: &eventbus.SystemMetricData{Percent: 70.0},
|
||||
})
|
||||
if ok {
|
||||
t.Error("expected no embed when Memory is below threshold")
|
||||
}
|
||||
|
||||
// Memory above threshold -> Orange embed
|
||||
embedMem, ok := sub.FormatEmbed(eventbus.Event{
|
||||
Type: eventbus.EventMemoryHigh,
|
||||
Timestamp: now,
|
||||
Data: &eventbus.SystemMetricData{Percent: 90.0},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected embed when Memory is above threshold")
|
||||
}
|
||||
if embedMem.Color != ColorOrange {
|
||||
t.Errorf("expected ColorOrange (0x%X), got 0x%X", ColorOrange, embedMem.Color)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatEmbed_LoginAttempt(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
discordService := NewDiscordService(settingService)
|
||||
sub := NewSubscriber(settingService, discordService)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Login success -> Green
|
||||
successEvent := eventbus.Event{
|
||||
Type: eventbus.EventLoginAttempt,
|
||||
Timestamp: now,
|
||||
Data: &eventbus.LoginEventData{
|
||||
Username: "admin",
|
||||
IP: "1.2.3.4",
|
||||
Time: "2026-09-12 12:00:00",
|
||||
Status: "success",
|
||||
},
|
||||
}
|
||||
embedSuccess, ok := sub.FormatEmbed(successEvent)
|
||||
if !ok {
|
||||
t.Fatal("expected embed for login success")
|
||||
}
|
||||
if embedSuccess.Color != ColorGreen {
|
||||
t.Errorf("expected ColorGreen, got 0x%X", embedSuccess.Color)
|
||||
}
|
||||
|
||||
// Login fail -> Red
|
||||
failEvent := eventbus.Event{
|
||||
Type: eventbus.EventLoginAttempt,
|
||||
Timestamp: now,
|
||||
Data: &eventbus.LoginEventData{
|
||||
Username: "attacker",
|
||||
IP: "5.6.7.8",
|
||||
Time: "2026-09-12 12:01:00",
|
||||
Status: "fail",
|
||||
Reason: "wrong password",
|
||||
},
|
||||
}
|
||||
embedFail, ok := sub.FormatEmbed(failEvent)
|
||||
if !ok {
|
||||
t.Fatal("expected embed for login failure")
|
||||
}
|
||||
if embedFail.Color != ColorRed {
|
||||
t.Errorf("expected ColorRed, got 0x%X", embedFail.Color)
|
||||
}
|
||||
|
||||
// Fallback when data is nil
|
||||
fallbackEvent := eventbus.Event{
|
||||
Type: eventbus.EventLoginAttempt,
|
||||
Source: "unknown-source",
|
||||
Timestamp: now,
|
||||
}
|
||||
embedFallback, ok := sub.FormatEmbed(fallbackEvent)
|
||||
if !ok {
|
||||
t.Fatal("expected embed for fallback login")
|
||||
}
|
||||
if embedFallback.Color != ColorRed {
|
||||
t.Errorf("expected ColorRed, got 0x%X", embedFallback.Color)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanField_Protection(t *testing.T) {
|
||||
field := cleanField("", " ", true)
|
||||
if field.Name != "-" || field.Value != "-" {
|
||||
t.Errorf("expected '-' for empty field name/value, got name=%q, value=%q", field.Name, field.Value)
|
||||
}
|
||||
|
||||
field2 := cleanField(" Name ", " Value ", false)
|
||||
if field2.Name != "Name" || field2.Value != "Value" {
|
||||
t.Errorf("expected trimmed name/value, got name=%q, value=%q", field2.Name, field2.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleEvent_EndToEndWithServer(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotEnable(true)
|
||||
_ = settingService.SetDiscordBotToken("test-token")
|
||||
_ = settingService.SetDiscordChannelId("ch-test")
|
||||
_ = settingService.SetDiscordEnabledEvents("login.attempt,outbound.down")
|
||||
|
||||
receivedCh := make(chan MessagePayload, 10)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var p MessagePayload
|
||||
_ = json.Unmarshal(body, &p)
|
||||
receivedCh <- p
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
discordService := NewDiscordService(settingService)
|
||||
discordService.SetBaseURL(server.URL)
|
||||
discordService.SetHTTPClient(server.Client())
|
||||
|
||||
sub := NewSubscriber(settingService, discordService)
|
||||
|
||||
// 1. Send enabled event (outbound.down)
|
||||
sub.HandleEvent(eventbus.Event{
|
||||
Type: eventbus.EventOutboundDown,
|
||||
Source: "out-1",
|
||||
Timestamp: time.Now().UTC(),
|
||||
})
|
||||
|
||||
select {
|
||||
case p := <-receivedCh:
|
||||
if len(p.Embeds) != 1 || p.Embeds[0].Color != ColorRed {
|
||||
t.Errorf("unexpected payload: %+v", p)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for outbound.down message")
|
||||
}
|
||||
|
||||
// 2. Duplicate outbound.down within rate limit -> should be suppressed
|
||||
sub.HandleEvent(eventbus.Event{
|
||||
Type: eventbus.EventOutboundDown,
|
||||
Source: "out-1",
|
||||
Timestamp: time.Now().UTC(),
|
||||
})
|
||||
|
||||
select {
|
||||
case p := <-receivedCh:
|
||||
t.Fatalf("rate limited event was unexpectedly sent: %+v", p)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
// OK
|
||||
}
|
||||
|
||||
// 3. Login attempt bypasses rate limit
|
||||
sub.HandleEvent(eventbus.Event{
|
||||
Type: eventbus.EventLoginAttempt,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Data: &eventbus.LoginEventData{
|
||||
Username: "admin",
|
||||
IP: "1.1.1.1",
|
||||
Status: "success",
|
||||
},
|
||||
})
|
||||
sub.HandleEvent(eventbus.Event{
|
||||
Type: eventbus.EventLoginAttempt,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Data: &eventbus.LoginEventData{
|
||||
Username: "admin",
|
||||
IP: "1.1.1.1",
|
||||
Status: "success",
|
||||
},
|
||||
})
|
||||
|
||||
// Both should arrive
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case <-receivedCh:
|
||||
// OK
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timed out waiting for login attempt message %d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Disabled event type (cpu.high is not in discordEnabledEvents)
|
||||
_ = settingService.SetDiscordCpu(50)
|
||||
sub.HandleEvent(eventbus.Event{
|
||||
Type: eventbus.EventCPUHigh,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Data: &eventbus.SystemMetricData{Percent: 99.0},
|
||||
})
|
||||
|
||||
select {
|
||||
case p := <-receivedCh:
|
||||
t.Fatalf("disabled event was unexpectedly sent: %+v", p)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
// OK
|
||||
}
|
||||
|
||||
// 5. Bot disabled entirely
|
||||
_ = settingService.SetDiscordBotEnable(false)
|
||||
sub.HandleEvent(eventbus.Event{
|
||||
Type: eventbus.EventLoginAttempt,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Data: &eventbus.LoginEventData{
|
||||
Username: "admin",
|
||||
IP: "1.1.1.1",
|
||||
Status: "success",
|
||||
},
|
||||
})
|
||||
|
||||
select {
|
||||
case p := <-receivedCh:
|
||||
t.Fatalf("event sent while bot disabled: %+v", p)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
// OK
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanField_Truncation(t *testing.T) {
|
||||
longName := strings.Repeat("А", 300) // 300 runes of 2-byte UTF-8
|
||||
longValue := strings.Repeat("🔥", 1200) // 1200 runes of 4-byte UTF-8
|
||||
|
||||
field := cleanField(longName, longValue, false)
|
||||
nameRunes := []rune(field.Name)
|
||||
valRunes := []rune(field.Value)
|
||||
|
||||
if len(nameRunes) > 256 {
|
||||
t.Errorf("expected name runes <= 256, got %d", len(nameRunes))
|
||||
}
|
||||
if !strings.HasSuffix(field.Name, "...") {
|
||||
t.Errorf("expected truncated name to end with '...', got %s", field.Name)
|
||||
}
|
||||
|
||||
if len(valRunes) > 1024 {
|
||||
t.Errorf("expected value runes <= 1024, got %d", len(valRunes))
|
||||
}
|
||||
if !strings.HasSuffix(field.Value, "...") {
|
||||
t.Errorf("expected truncated value to end with '...', got %s", field.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleEvent_BelowThresholdDoesNotBurnRateLimiter(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotEnable(true)
|
||||
_ = settingService.SetDiscordBotToken("test-token")
|
||||
_ = settingService.SetDiscordChannelId("ch-test")
|
||||
_ = settingService.SetDiscordEnabledEvents("cpu.high")
|
||||
_ = settingService.SetDiscordCpu(80)
|
||||
|
||||
receivedCh := make(chan MessagePayload, 5)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var p MessagePayload
|
||||
_ = json.Unmarshal(body, &p)
|
||||
receivedCh <- p
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
discordService := NewDiscordService(settingService)
|
||||
discordService.SetBaseURL(server.URL)
|
||||
discordService.SetHTTPClient(server.Client())
|
||||
|
||||
sub := NewSubscriber(settingService, discordService)
|
||||
|
||||
// 1. CPU at 50% (below 80% threshold) - must NOT be sent and must NOT burn rate limiter
|
||||
sub.HandleEvent(eventbus.Event{
|
||||
Type: eventbus.EventCPUHigh,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Data: &eventbus.SystemMetricData{Percent: 50.0},
|
||||
})
|
||||
|
||||
select {
|
||||
case p := <-receivedCh:
|
||||
t.Fatalf("sub-threshold CPU event was unexpectedly sent: %+v", p)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
// OK
|
||||
}
|
||||
|
||||
// 2. CPU immediately spikes to 95% (above 80% threshold) - MUST be sent!
|
||||
sub.HandleEvent(eventbus.Event{
|
||||
Type: eventbus.EventCPUHigh,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Data: &eventbus.SystemMetricData{Percent: 95.0},
|
||||
})
|
||||
|
||||
select {
|
||||
case p := <-receivedCh:
|
||||
if len(p.Embeds) != 1 || p.Embeds[0].Color != ColorOrange {
|
||||
t.Errorf("unexpected payload for critical CPU alert: %+v", p)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("critical CPU alert was incorrectly suppressed by rate limiter after below-threshold event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatEmbed_ValueTypes(t *testing.T) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordCpu(80)
|
||||
_ = settingService.SetDiscordMemory(80)
|
||||
sub := NewSubscriber(settingService, NewDiscordService(settingService))
|
||||
now := time.Now().UTC()
|
||||
|
||||
// OutboundHealthData by value
|
||||
embed, ok := sub.FormatEmbed(eventbus.Event{
|
||||
Type: eventbus.EventOutboundDown,
|
||||
Source: "out-val",
|
||||
Timestamp: now,
|
||||
Data: eventbus.OutboundHealthData{
|
||||
Delay: 350,
|
||||
Error: "connection lost",
|
||||
},
|
||||
})
|
||||
if !ok || len(embed.Fields) != 3 {
|
||||
t.Fatalf("expected 3 fields for OutboundDown value type, got ok=%v, fields=%d", ok, len(embed.Fields))
|
||||
}
|
||||
|
||||
// NodeHealthData by value
|
||||
embedNode, ok := sub.FormatEmbed(eventbus.Event{
|
||||
Type: eventbus.EventNodeUp,
|
||||
Source: "node-val",
|
||||
Timestamp: now,
|
||||
Data: eventbus.NodeHealthData{
|
||||
LatencyMs: 25,
|
||||
},
|
||||
})
|
||||
if !ok || len(embedNode.Fields) != 2 {
|
||||
t.Fatalf("expected 2 fields for NodeUp value type, got ok=%v, fields=%d", ok, len(embedNode.Fields))
|
||||
}
|
||||
|
||||
// SystemMetricData by value
|
||||
embedCPU, ok := sub.FormatEmbed(eventbus.Event{
|
||||
Type: eventbus.EventCPUHigh,
|
||||
Timestamp: now,
|
||||
Data: eventbus.SystemMetricData{
|
||||
Percent: 90.0,
|
||||
},
|
||||
})
|
||||
if !ok || embedCPU.Color != ColorOrange {
|
||||
t.Fatalf("expected orange embed for CPU high value type, got ok=%v", ok)
|
||||
}
|
||||
|
||||
// LoginEventData by value
|
||||
embedLogin, ok := sub.FormatEmbed(eventbus.Event{
|
||||
Type: eventbus.EventLoginAttempt,
|
||||
Timestamp: now,
|
||||
Data: eventbus.LoginEventData{
|
||||
Username: "admin",
|
||||
IP: "127.0.0.1",
|
||||
Status: "success",
|
||||
},
|
||||
})
|
||||
if !ok || embedLogin.Color != ColorGreen {
|
||||
t.Fatalf("expected green embed for Login success value type, got ok=%v", ok)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user