瀏覽代碼

fix(discord): page the inbounds reply within Discord's embed caps (#6496)

`!inbounds` built a single embed with one field per inbound and sent it as
it was. Discord rejects the whole message past 25 fields, ten embeds or 6000
counted characters, so an operator holding more than 25 inbounds got no
answer at all, and a remark longer than ~252 runes broke the command on its
own — the `📍 ` prefix spends four units of the same 256-unit field name cap.

The failure left no trace either: the send error was discarded, so the
channel stayed empty and the log stayed quiet.

Fields are now capped by the same helper every other reply in the package
uses for its name and value limits, and packed into messages that fit those
caps, with the header leading only the first embed of each message. The caps
are counted the way Discord counts them, in UTF-16 units, and a page it
answers with a 429 is waited out once rather than dropping the pages behind
it.
BlindMaster24 20 小時之前
父節點
當前提交
d45a09d634

+ 22 - 1
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)
 	}

+ 73 - 13
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))
+	}
+
+	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)
+		}
 	}
+}
 
-	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")},
+// 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
 	}
-	_ = g.discordService.SendEmbed(ctx, embed)
+	if len(group) > 0 {
+		groups = append(groups, group)
+	}
+	return groups
 }
 
 func (g *GatewayClient) restartXray(ctx context.Context) {

+ 218 - 0
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))
+	}
+}

+ 28 - 9
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)
+	}
+	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 string(r[:maxRunes-3]) + "..."
+	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)