Fix Telegram bot client lookup for multi-node inbounds

This commit is contained in:
Serega71RUS 2026-05-28 14:06:13 +03:00
parent a07b68894c
commit c64cdbb0ed
2 changed files with 251 additions and 32 deletions

View file

@ -2620,10 +2620,144 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffic, error) {
db := database.GetDB()
var emails []string
// Prefer the normalized clients table. It is populated for both local
// inbounds and remote-node snapshots, and avoids brittle JSON LIKE matching
// against settings formatting.
err := db.Table("clients").
Select("DISTINCT clients.email").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id").
Where("clients.tg_id = ? AND clients.email <> ''", tgId).
Pluck("clients.email", &emails).Error
if err != nil {
logger.Errorf("Error retrieving client emails for tgId %d: %v", tgId, err)
return nil, err
}
if len(emails) == 0 {
emails, err = s.getClientTrafficTgBotFromSettings(tgId)
if err != nil {
return nil, err
}
}
uniqEmails := uniqueNonEmptyStrings(emails)
if len(uniqEmails) == 0 {
logger.Warningf("No clients found for tgId: %d", tgId)
return nil, nil
}
trafficsByEmail := make(map[string]*xray.ClientTraffic, len(uniqEmails))
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
var page []*xray.ClientTraffic
if err = db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
if err == gorm.ErrRecordNotFound {
continue
}
logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err)
return nil, err
}
for _, traffic := range page {
trafficsByEmail[traffic.Email] = traffic
}
}
missingEmails := make([]string, 0)
for _, email := range uniqEmails {
if _, ok := trafficsByEmail[email]; !ok {
missingEmails = append(missingEmails, email)
}
}
if len(missingEmails) > 0 {
type clientTrafficSeed struct {
Email string
InboundId int
Enable bool
TotalGB int64
ExpiryTime int64
Reset int
UUID string
SubID string
}
var rows []clientTrafficSeed
for _, batch := range chunkStrings(missingEmails, sqliteMaxVars) {
var page []clientTrafficSeed
if err = db.Table("clients").
Select(`clients.email,
client_inbounds.inbound_id,
clients.enable,
clients.total_gb,
clients.expiry_time,
clients.reset,
clients.uuid,
clients.sub_id`).
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("clients.email IN ?", batch).
Find(&page).Error; err != nil {
logger.Errorf("Error retrieving client records for emails %v: %v", batch, err)
return nil, err
}
rows = append(rows, page...)
}
for _, row := range rows {
if _, ok := trafficsByEmail[row.Email]; ok {
continue
}
trafficsByEmail[row.Email] = &xray.ClientTraffic{
InboundId: row.InboundId,
Email: row.Email,
Enable: row.Enable,
Total: row.TotalGB,
ExpiryTime: row.ExpiryTime,
Reset: row.Reset,
UUID: row.UUID,
SubId: row.SubID,
}
}
}
traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails))
for _, email := range uniqEmails {
traffic, ok := trafficsByEmail[email]
if !ok {
continue
}
if ct, client, e := s.GetClientByEmail(email); e == nil && ct != nil && client != nil {
traffic.Enable = client.Enable
traffic.UUID = client.ID
traffic.SubId = client.SubID
} else if traffic.UUID == "" || traffic.SubId == "" {
clients, clientErr := s.clientService.ListForInbound(nil, traffic.InboundId)
if clientErr != nil {
logger.Errorf("Error retrieving clients for inbound %d: %v", traffic.InboundId, clientErr)
}
for _, client := range clients {
if client.Email == email {
traffic.Enable = client.Enable
traffic.UUID = client.ID
traffic.SubId = client.SubID
break
}
}
}
traffics = append(traffics, traffic)
}
if len(traffics) == 0 {
logger.Warning("No ClientTraffic records found for emails:", emails)
return nil, nil
}
return traffics, nil
}
func (s *InboundService) getClientTrafficTgBotFromSettings(tgId int64) ([]string, error) {
db := database.GetDB()
var inbounds []*model.Inbound
// Retrieve inbounds where settings contain the given tgId
err := db.Model(model.Inbound{}).Where("settings LIKE ?", fmt.Sprintf(`%%"tgId": %d%%`, tgId)).Find(&inbounds).Error
err := db.Model(model.Inbound{}).Where("settings LIKE ?", fmt.Sprintf(`%%"tgId"%%%d%%`, tgId)).Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
return nil, err
@ -2643,36 +2777,7 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
}
}
// Chunked to stay under SQLite's bind-variable limit when a single Telegram
// account owns thousands of clients across inbounds.
uniqEmails := uniqueNonEmptyStrings(emails)
traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails))
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
var page []*xray.ClientTraffic
if err = db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
if err == gorm.ErrRecordNotFound {
continue
}
logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err)
return nil, err
}
traffics = append(traffics, page...)
}
if len(traffics) == 0 {
logger.Warning("No ClientTraffic records found for emails:", emails)
return nil, nil
}
// Populate UUID and other client data for each traffic record
for i := range traffics {
if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil {
traffics[i].Enable = client.Enable
traffics[i].UUID = client.ID
traffics[i].SubId = client.SubID
}
}
return traffics, nil
return emails, nil
}
// sqliteMaxVars is a safe ceiling for the number of bind parameters in a

View file

@ -0,0 +1,114 @@
package service
import (
"fmt"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/xray"
)
func setupTgBotTrafficTestDB(t *testing.T) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
}
func TestGetClientTrafficTgBotUsesNormalizedRemoteNodeClients(t *testing.T) {
setupTgBotTrafficTestDB(t)
db := database.GetDB()
nodeID := 7
inbound := &model.Inbound{
NodeID: &nodeID,
Tag: "node-vless",
Enable: true,
Port: 10001,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
}
if err := db.Create(inbound).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
const tgID int64 = 505739390
const email = "remote-user@example.com"
const uuid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c010"
const subID = "remote-sub-id"
clientSvc := ClientService{}
if err := clientSvc.SyncInbound(nil, inbound.Id, []model.Client{{
Email: email,
ID: uuid,
SubID: subID,
Enable: true,
TgID: tgID,
}}); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: inbound.Id,
Email: email,
Enable: true,
Total: 1024,
}).Error; err != nil {
t.Fatalf("create traffic: %v", err)
}
traffics, err := (&InboundService{}).GetClientTrafficTgBot(tgID)
if err != nil {
t.Fatalf("GetClientTrafficTgBot: %v", err)
}
if len(traffics) != 1 {
t.Fatalf("expected one traffic row, got %d", len(traffics))
}
if traffics[0].Email != email || traffics[0].UUID != uuid || traffics[0].SubId != subID {
t.Fatalf("unexpected traffic: %#v", traffics[0])
}
}
func TestGetClientTrafficTgBotFallsBackToCompactSettingsJSON(t *testing.T) {
setupTgBotTrafficTestDB(t)
db := database.GetDB()
const tgID int64 = 505739390
const email = "legacy-user@example.com"
const uuid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c011"
const subID = "legacy-sub-id"
inbound := &model.Inbound{
Tag: "legacy-vless",
Enable: true,
Port: 10002,
Protocol: model.VLESS,
Settings: fmt.Sprintf(`{"clients":[{"email":%q,"id":%q,"subId":%q,"enable":true,"tgId":%d}]}`, email, uuid, subID, tgID),
}
if err := db.Create(inbound).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: inbound.Id,
Email: email,
Enable: true,
}).Error; err != nil {
t.Fatalf("create traffic: %v", err)
}
traffics, err := (&InboundService{}).GetClientTrafficTgBot(tgID)
if err != nil {
t.Fatalf("GetClientTrafficTgBot: %v", err)
}
if len(traffics) != 1 {
t.Fatalf("expected one traffic row, got %d", len(traffics))
}
if traffics[0].Email != email || traffics[0].UUID != uuid || traffics[0].SubId != subID {
t.Fatalf("unexpected traffic: %#v", traffics[0])
}
}