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.
This commit is contained in:
BlindMaster24
2026-09-13 20:47:49 +03:00
committed by GitHub
parent 5c34baa8df
commit d45a09d634
4 changed files with 341 additions and 23 deletions
+22 -1
View File
@@ -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)
}