mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
d45a09d634
`!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.
358 lines
8.9 KiB
Go
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
|
|
}
|