Files
3x-ui/internal/web/service/discord/subscriber.go
T
BlindMaster24 d45a09d634 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.
2026-09-13 19:47:49 +02:00

358 lines
8.9 KiB
Go

package discord
import (
"context"
"fmt"
"os"
"strings"
"time"
"unicode/utf16"
"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
}
// 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
}
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 b.String() + suffix
}
func cleanField(name, value string, inline bool) EmbedField {
name = strings.TrimSpace(name)
if name == "" {
name = "-"
} else {
name = truncateUnits(name, 256)
}
value = strings.TrimSpace(value)
if value == "" {
value = "-"
} else {
value = truncateUnits(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: truncateUnits("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
}