diff --git a/internal/web/service/discord/discord.go b/internal/web/service/discord/discord.go index 19f296470..1cd6ad0e5 100644 --- a/internal/web/service/discord/discord.go +++ b/internal/web/service/discord/discord.go @@ -12,6 +12,7 @@ import ( "os" "strings" "time" + "unicode/utf16" "github.com/mhsanaei/3x-ui/v3/internal/web/locale" "github.com/mhsanaei/3x-ui/v3/internal/web/service" @@ -119,6 +120,22 @@ func (s *DiscordService) authCredentials() (token string, channelID string, err return cleanToken, strings.TrimSpace(rawChannel), nil } +// discordCharLen counts what Discord's caps count: a rune outside the BMP is +// two units there, so a rune count understates an emoji-bearing field. +func discordCharLen(s string) int { + return len(utf16.Encode([]rune(s))) +} + +// RateLimitedError is a 429, carrying the wait Discord asks for so a caller +// sending several messages can back off instead of losing the rest of them. +type RateLimitedError struct { + RetryAfter time.Duration +} + +func (e *RateLimitedError) Error() string { + return fmt.Sprintf("discord rate limited (429): retry after %s", e.RetryAfter) +} + func parseDiscordResponse(resp *http.Response) error { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) bodyStr := string(respBody) @@ -135,7 +152,11 @@ func parseDiscordResponse(resp *http.Response) error { 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) + var limited struct { + RetryAfter float64 `json:"retry_after"` + } + _ = json.Unmarshal(respBody, &limited) + return &RateLimitedError{RetryAfter: time.Duration(limited.RetryAfter * float64(time.Second))} default: return fmt.Errorf("discord API error (%d): %s", resp.StatusCode, bodyStr) } diff --git a/internal/web/service/discord/gateway.go b/internal/web/service/discord/gateway.go index 3a9136142..98b540681 100644 --- a/internal/web/service/discord/gateway.go +++ b/internal/web/service/discord/gateway.go @@ -34,6 +34,16 @@ const ( // GUILDS (1<<0) | GUILD_MESSAGES (1<<9) | DIRECT_MESSAGES (1<<12) | MESSAGE_CONTENT (1<<15) discordIntents = 37377 + + // Discord rejects a whole message past these caps: 25 fields per embed, ten + // embeds, 6000 counted characters. + discordEmbedFieldLimit = 25 + discordEmbedsPerMsg = 10 + discordMessageCharLimit = 6000 + + // How long a 429 may ask a paged reply to wait before it gives up on the + // page: longer than this and the operator is staring at a dead command. + discordRateLimitWait = 5 * time.Second ) // GatewayPayload represents a Discord Gateway WebSocket frame. @@ -660,22 +670,72 @@ func (g *GatewayClient) sendInbounds(ctx context.Context) { "Down=="+common.FormatTraffic(in.Down), "State=="+state, ) - fields = append(fields, EmbedField{ - Name: fmt.Sprintf("📍 %s", in.Remark), - Value: val, - Inline: false, - }) + fields = append(fields, cleanField(fmt.Sprintf("📍 %s", in.Remark), val, 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")}, + title := tr("discord.commands.inboundsTitle") + description := tr("discord.commands.inboundsDescription", "Count=="+strconv.Itoa(len(inbounds))) + footer := tr("discord.footer") + overhead := discordCharLen(title) + discordCharLen(description) + discordCharLen(footer) + now := time.Now().UTC().Format(time.RFC3339) + + for _, group := range splitInboundFields(fields, overhead) { + embeds := make([]Embed, 0, len(group)/discordEmbedFieldLimit+1) + for start := 0; start < len(group); start += discordEmbedFieldLimit { + embed := Embed{ + Color: ColorBlue, + Timestamp: now, + Fields: group[start:min(start+discordEmbedFieldLimit, len(group))], + } + // The header leads the reply only once per message; later embeds of a + // paged panel would otherwise repeat it for every 25 inbounds. + if len(embeds) == 0 { + embed.Title = title + embed.Description = description + embed.Footer = &EmbedFooter{Text: footer} + } + embeds = append(embeds, embed) + } + + payload := MessagePayload{Embeds: embeds} + err := g.discordService.SendMessage(ctx, payload) + var limited *RateLimitedError + if errors.As(err, &limited) && limited.RetryAfter > 0 && limited.RetryAfter <= discordRateLimitWait { + select { + case <-ctx.Done(): + return + case <-time.After(limited.RetryAfter): + } + err = g.discordService.SendMessage(ctx, payload) + } + // One page Discord refused must not take the pages behind it down: the + // operator is better served by a partial list than by nothing at all. + if err != nil { + logger.Warning("Discord inbounds command: send failed: ", err) + } } - _ = g.discordService.SendEmbed(ctx, embed) +} + +// splitInboundFields packs fields into groups that each fit one Discord message, +// within the character count Discord counts across its embeds and its embed cap. +func splitInboundFields(fields []EmbedField, overhead int) [][]EmbedField { + groups := make([][]EmbedField, 0, 1) + group := make([]EmbedField, 0, discordEmbedFieldLimit) + chars := overhead + for _, field := range fields { + size := discordCharLen(field.Name) + discordCharLen(field.Value) + if len(group) > 0 && (len(group) >= discordEmbedFieldLimit*discordEmbedsPerMsg || chars+size > discordMessageCharLimit) { + groups = append(groups, group) + group = make([]EmbedField, 0, discordEmbedFieldLimit) + chars = overhead + } + group = append(group, field) + chars += size + } + if len(group) > 0 { + groups = append(groups, group) + } + return groups } func (g *GatewayClient) restartXray(ctx context.Context) { diff --git a/internal/web/service/discord/gateway_inbounds_limits_test.go b/internal/web/service/discord/gateway_inbounds_limits_test.go new file mode 100644 index 000000000..d40692c17 --- /dev/null +++ b/internal/web/service/discord/gateway_inbounds_limits_test.go @@ -0,0 +1,218 @@ +package discord + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "unicode/utf16" + + "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" +) + +// Discord's published caps, pinned here and not read from the package so the +// assertions still redden if those caps are ever loosened. +const ( + discordFieldNameLimit = 256 + discordFieldValueLimit = 1024 + discordEmbedFieldCap = 25 + discordEmbedsPerMessage = 10 + discordMessageCharCap = 6000 +) + +// utf16Units counts the way Discord counts: its caps follow JavaScript string +// length, where an astral rune is two units. +func utf16Units(s string) int { + return len(utf16.Encode([]rune(s))) +} + +func inboundFixtures(count int) []*model.Inbound { + inbounds := make([]*model.Inbound, 0, count) + for i := range count { + inbounds = append(inbounds, &model.Inbound{ + Id: i + 1, + Remark: fmt.Sprintf("inbound-%d", i), + Port: 10000 + i, + Protocol: "vless", + Enable: i%2 == 0, + ClientStats: []xray.ClientTraffic{ + {Email: fmt.Sprintf("client-%d@test", i), Enable: true}, + }, + }) + } + return inbounds +} + +// assertsDiscordLimits checks every message against the caps Discord enforces and +// returns how many inbound fields the reply carried in total. +func assertsDiscordLimits(t *testing.T, msgs []MessagePayload) int { + t.Helper() + fields := 0 + for i, msg := range msgs { + if len(msg.Embeds) > discordEmbedsPerMessage { + t.Errorf("message %d carries %d embeds, Discord accepts %d", i, len(msg.Embeds), discordEmbedsPerMessage) + } + chars := 0 + for _, embed := range msg.Embeds { + footer := "" + if embed.Footer != nil { + footer = embed.Footer.Text + } + chars += utf16Units(embed.Title) + utf16Units(embed.Description) + utf16Units(footer) + if len(embed.Fields) > discordEmbedFieldCap { + t.Errorf("message %d has an embed with %d fields, Discord accepts %d", i, len(embed.Fields), discordEmbedFieldCap) + } + for _, field := range embed.Fields { + fields++ + chars += utf16Units(field.Name) + utf16Units(field.Value) + if n := utf16Units(field.Name); n > discordFieldNameLimit { + t.Errorf("field name is %d units, Discord accepts %d", n, discordFieldNameLimit) + } + if n := utf16Units(field.Value); n > discordFieldValueLimit { + t.Errorf("field value is %d units, Discord accepts %d", n, discordFieldValueLimit) + } + } + } + if chars > discordMessageCharCap { + t.Errorf("message %d carries %d units, Discord accepts %d", i, chars, discordMessageCharCap) + } + } + return fields +} + +// runInboundsCommandWith drives !inbounds against a channel that answers each +// POST through respond, reporting what Discord accepted and the POST count. +func runInboundsCommandWith(t *testing.T, settingService service.SettingService, inbounds []*model.Inbound, respond func(post int) (int, string)) ([]MessagePayload, int) { + t.Helper() + var mu sync.Mutex + var sent []MessagePayload + posts := 0 + restServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload MessagePayload + _ = json.NewDecoder(r.Body).Decode(&payload) + mu.Lock() + posts++ + post := posts + mu.Unlock() + + status, body := respond(post) + if status == http.StatusOK { + mu.Lock() + sent = append(sent, payload) + mu.Unlock() + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(restServer.Close) + + svc := NewDiscordService(settingService) + svc.SetBaseURL(restServer.URL) + svc.SetHTTPClient(restServer.Client()) + + gw := NewGatewayClient(svc, settingService, &mockServerProvider{}, &mockInboundProvider{inbounds: inbounds}, &mockXrayRestart{}) + gw.handleMessage(context.Background(), MessageCreateData{ + ID: "m1", + ChannelID: "ch-1", + Content: "!inbounds", + Author: struct { + ID string `json:"id"` + Username string `json:"username"` + Bot bool `json:"bot"` + }{ID: "u1", Username: "Alice"}, + }) + + mu.Lock() + defer mu.Unlock() + return append([]MessagePayload(nil), sent...), posts +} + +func runInboundsCommand(t *testing.T, settingService service.SettingService, inbounds []*model.Inbound) []MessagePayload { + t.Helper() + msgs, _ := runInboundsCommandWith(t, settingService, inbounds, func(int) (int, string) { + return http.StatusOK, `{"id": "msg-1"}` + }) + return msgs +} + +func TestInboundsCommandStaysWithinDiscordLimits(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("test-bot-token") + _ = settingService.SetDiscordChannelId("ch-1") + _ = settingService.SetDiscordAdminIds("u1") + + t.Run("a large panel is paged instead of rejected", func(t *testing.T) { + const count = 300 + msgs := runInboundsCommand(t, settingService, inboundFixtures(count)) + + if got := assertsDiscordLimits(t, msgs); got != count { + t.Errorf("reply listed %d inbounds, want %d", got, count) + } + if len(msgs) < 2 { + t.Errorf("%d inbounds reached Discord in %d message(s), want the reply paged", count, len(msgs)) + } + }) + + t.Run("a remark past the field name cap is truncated, not dropped", func(t *testing.T) { + inbounds := inboundFixtures(3) + inbounds[0].Remark = strings.Repeat("r", 400) + msgs := runInboundsCommand(t, settingService, inbounds) + + if got := assertsDiscordLimits(t, msgs); got != len(inbounds) { + t.Fatalf("reply listed %d inbounds, want %d", got, len(inbounds)) + } + if len(msgs) != 1 { + t.Fatalf("expected one message for %d inbounds, got %d", len(inbounds), len(msgs)) + } + name := msgs[0].Embeds[0].Fields[0].Name + if units := utf16Units(name); units != discordFieldNameLimit { + t.Errorf("truncated name is %d units, want %d: %q", units, discordFieldNameLimit, name) + } + if !strings.HasPrefix(name, "📍 "+strings.Repeat("r", 100)) { + t.Errorf("truncated name lost the remark: %q", name) + } + }) + + t.Run("an astral remark is cut to the cap, which runes would overshoot", func(t *testing.T) { + inbounds := inboundFixtures(40) + for _, in := range inbounds { + in.Remark = strings.Repeat("🚀", 300) + } + msgs := runInboundsCommand(t, settingService, inbounds) + + if got := assertsDiscordLimits(t, msgs); got != len(inbounds) { + t.Errorf("reply listed %d inbounds, want %d", got, len(inbounds)) + } + }) +} + +func TestInboundsCommandRetriesARateLimitedPage(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("test-bot-token") + _ = settingService.SetDiscordChannelId("ch-1") + _ = settingService.SetDiscordAdminIds("u1") + + const count = 300 + msgs, posts := runInboundsCommandWith(t, settingService, inboundFixtures(count), func(post int) (int, string) { + if post == 1 { + return http.StatusTooManyRequests, `{"message": "You are being rate limited.", "retry_after": 0.05}` + } + return http.StatusOK, `{"id": "msg-1"}` + }) + + if got := assertsDiscordLimits(t, msgs); got != count { + t.Errorf("reply listed %d inbounds after the retry, want %d", got, count) + } + if len(msgs) < 2 { + t.Errorf("rate limited page left %d message(s), want the rest of the reply", len(msgs)) + } + if posts != len(msgs)+1 { + t.Errorf("posted %d times for %d messages, want one retry of the limited page", posts, len(msgs)) + } +} diff --git a/internal/web/service/discord/subscriber.go b/internal/web/service/discord/subscriber.go index 85ffac080..cad635632 100644 --- a/internal/web/service/discord/subscriber.go +++ b/internal/web/service/discord/subscriber.go @@ -6,6 +6,7 @@ import ( "os" "strings" "time" + "unicode/utf16" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -68,15 +69,33 @@ func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool { return false } -func truncateRunes(s string, maxRunes int) string { - r := []rune(s) - if len(r) <= maxRunes { +// truncateUnits cuts s to maxUnits of the length Discord measures its field +// name, value and footer caps by: runes alone overrun them on astral text. +func truncateUnits(s string, maxUnits int) string { + if discordCharLen(s) <= maxUnits { return s } - if maxRunes <= 3 { - return string(r[:maxRunes]) + suffix := "..." + budget := maxUnits + if maxUnits <= len(suffix) { + suffix = "" + } else { + budget -= len(suffix) } - return string(r[:maxRunes-3]) + "..." + var b strings.Builder + units := 0 + for _, r := range s { + size := utf16.RuneLen(r) + if size < 1 { + size = 1 + } + if units+size > budget { + break + } + b.WriteRune(r) + units += size + } + return b.String() + suffix } func cleanField(name, value string, inline bool) EmbedField { @@ -84,13 +103,13 @@ func cleanField(name, value string, inline bool) EmbedField { if name == "" { name = "-" } else { - name = truncateRunes(name, 256) + name = truncateUnits(name, 256) } value = strings.TrimSpace(value) if value == "" { value = "-" } else { - value = truncateRunes(value, 1024) + value = truncateUnits(value, 1024) } return EmbedField{ Name: name, @@ -114,7 +133,7 @@ func (s *Subscriber) FormatEmbed(e eventbus.Event) (Embed, bool) { } footer := &EmbedFooter{ - Text: truncateRunes("3x-ui • "+h, 2048), + Text: truncateUnits("3x-ui • "+h, 2048), } tr := translator(s.settingService)