mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 01:55:25 +08:00
fix(tgbot): look up a Telegram account's clients through the indexed client table (#6887)
Every bot usage command and the hourly report resolved a Telegram ID by expanding every inbound's settings.clients, re-parsing the matched inbounds and looking up each client again by email. With thousands of clients this ran thousands of times per report and showed up as high PostgreSQL CPU, and an account whose lookup came back empty was retried once per client carrying it. The lookup now uses the normalized clients table and its Telegram-ID index in one query per account, and the hourly pass checks each account once. Bot output and thresholds are unchanged; link callbacks now authorize against the current client records instead of possibly stale settings JSON.
This commit is contained in:
@@ -92,6 +92,16 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: PostgreSQL Telegram lookup and legacy migration tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test ./internal/web/service -run '^(TestGetClientTrafficTgBot_IndexedPostgres|TestGetClientTrafficTgBot_SettingsSerializationStyles)$' -count=1 -v | tee /tmp/postgres-tgbot.log
|
||||
passed=$(grep -c -- '^--- PASS' /tmp/postgres-tgbot.log || true)
|
||||
if [ "$passed" -ne 2 ]; then
|
||||
echo "expected 2 passing PostgreSQL Telegram lookup tests, got $passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
codegen:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||
@@ -10,11 +18,8 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// TestGetClientTrafficTgBot_SettingsSerializationStyles guards against the
|
||||
// prefilter regressing into a formatting-sensitive string match (#5805): the
|
||||
// lookup must find clients whether inbounds.settings stores compact JSON
|
||||
// ("tgId":N, as written by node sync/import) or indented JSON ("tgId": N, as
|
||||
// written by the panel's MarshalIndent).
|
||||
// Legacy JSON of either serialization style must remain discoverable after
|
||||
// the supported ClientsTable startup migration (#5805, #6886).
|
||||
func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
@@ -31,6 +36,11 @@ func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) {
|
||||
}{
|
||||
{"compact", `{"clients":[{"id":"u1","email":"compact-user","tgId":123456789}]}`, "compact-user", 41001},
|
||||
{"spaced", `{"clients": [{"id": "u2", "email": "spaced-user", "tgId": 123456789}]}`, "spaced-user", 41002},
|
||||
{"indented", `{
|
||||
"clients": [
|
||||
{"id": "u3", "email": "indented-user", "tgId": 123456789}
|
||||
]
|
||||
}`, "indented-user", 41003},
|
||||
}
|
||||
for _, c := range cases {
|
||||
inbound := &model.Inbound{UserId: 1, Tag: "tg-" + c.name, Enable: true, Port: c.port, Protocol: model.VLESS, Settings: c.settings}
|
||||
@@ -40,6 +50,30 @@ func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) {
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: c.email, Enable: true, Up: 10, Down: 20}).Error; err != nil {
|
||||
t.Fatalf("create %s client_traffics: %v", c.name, err)
|
||||
}
|
||||
if c.name == "indented" {
|
||||
// The existing migration also repairs a missing link for an existing record.
|
||||
if err := db.Create(&model.ClientRecord{Email: c.email, UUID: "u3", TgID: tgId}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Model an upgrade from JSON-only storage, before the ClientsTable seeder.
|
||||
if err := db.Where("seeder_name = ?", "ClientsTable").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CloseDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var records []model.ClientRecord
|
||||
if err := database.GetDB().Where("tg_id = ?", tgId).Find(&records).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 3 {
|
||||
t.Fatalf("ClientsTable migration created %d records, want 3", len(records))
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
@@ -51,8 +85,8 @@ func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) {
|
||||
for _, tr := range traffics {
|
||||
got[tr.Email] = true
|
||||
}
|
||||
if len(traffics) != 2 || !got["compact-user"] || !got["spaced-user"] {
|
||||
t.Fatalf("expected traffic for compact-user and spaced-user, got %v", got)
|
||||
if len(traffics) != 3 || !got["compact-user"] || !got["spaced-user"] || !got["indented-user"] {
|
||||
t.Fatalf("expected traffic for compact-user, spaced-user and indented-user, got %v", got)
|
||||
}
|
||||
|
||||
other, err := svc.GetClientTrafficTgBot(42)
|
||||
@@ -63,3 +97,168 @@ func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) {
|
||||
t.Fatalf("expected no traffic for unknown tgId, got %d rows", len(other))
|
||||
}
|
||||
}
|
||||
|
||||
type tgLookupSQL struct {
|
||||
logger.Interface
|
||||
queries []string
|
||||
}
|
||||
|
||||
func (l *tgLookupSQL) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
|
||||
sql, _ := fc()
|
||||
l.queries = append(l.queries, sql)
|
||||
}
|
||||
|
||||
func TestGetClientTrafficTgBot_Indexed(t *testing.T) {
|
||||
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
|
||||
testIndexedTgLookup(t, database.GetDB())
|
||||
}
|
||||
|
||||
func TestGetClientTrafficTgBot_IndexedPostgres(t *testing.T) {
|
||||
testIndexedTgLookup(t, durablePostgresDB(t))
|
||||
}
|
||||
|
||||
func TestGetClientTrafficTgBot_LargeAccount(t *testing.T) {
|
||||
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
|
||||
db := database.GetDB()
|
||||
inbound := &model.Inbound{UserId: 1, Tag: "large-tg", Port: 41888, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
if err := db.Create(inbound).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const size = 1200
|
||||
records := make([]model.ClientRecord, size)
|
||||
traffics := make([]xray.ClientTraffic, size)
|
||||
links := make([]model.ClientInbound, size)
|
||||
for i := range size {
|
||||
email := fmt.Sprintf("large-%04d", i)
|
||||
records[i] = model.ClientRecord{Email: email, UUID: "uuid-" + email, SubID: "sub-" + email, TgID: 6886, Enable: true}
|
||||
traffics[i] = xray.ClientTraffic{Email: email, InboundId: inbound.Id, Up: int64(i)}
|
||||
}
|
||||
if err := db.CreateInBatches(&records, 100).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range size {
|
||||
links[i] = model.ClientInbound{ClientId: records[i].Id, InboundId: inbound.Id}
|
||||
}
|
||||
if err := db.CreateInBatches(&links, 100).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateInBatches(&traffics, 100).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trace := &tgLookupSQL{Interface: logger.Default}
|
||||
previous := db.Logger
|
||||
db.Logger = trace
|
||||
t.Cleanup(func() { db.Logger = previous })
|
||||
got, err := (&InboundService{}).GetClientTrafficTgBot(6886)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != size || len(trace.queries) != 1 {
|
||||
t.Fatalf("got %d rows in %d queries, want %d rows in 1 query", len(got), len(trace.queries), size)
|
||||
}
|
||||
for i, traffic := range got {
|
||||
if traffic.Email != records[i].Email || traffic.UUID != records[i].UUID || traffic.SubId != records[i].SubID || !traffic.Enable || traffic.Up != int64(i) {
|
||||
t.Fatalf("row %d lost ordering or metadata: %+v", i, traffic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testIndexedTgLookup(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
const tgID = int64(6886001)
|
||||
prefix := fmt.Sprintf("tg6886-%d-", time.Now().UnixNano())
|
||||
inbounds := []model.Inbound{
|
||||
{UserId: 1, Tag: prefix + "a", Port: 41886, Protocol: model.VLESS, Enable: true, Settings: `{"clients":[]}`},
|
||||
{UserId: 1, Tag: prefix + "b", Port: 41887, Protocol: model.VLESS, Settings: `{"clients":[]}`},
|
||||
}
|
||||
for i := range inbounds {
|
||||
if err := db.Create(&inbounds[i]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var ids []int
|
||||
t.Cleanup(func() {
|
||||
_ = db.Where("client_id IN ?", ids).Delete(&model.ClientInbound{}).Error
|
||||
_ = db.Where("email LIKE ?", prefix+"%").Delete(&xray.ClientTraffic{}).Error
|
||||
_ = db.Where("id IN ?", ids).Delete(&model.ClientRecord{}).Error
|
||||
for i := range inbounds {
|
||||
_ = db.Delete(&inbounds[i]).Error
|
||||
}
|
||||
})
|
||||
for i, name := range []string{"a", "b", "no-traffic", "foreign", "unbound", "deleted-inbound", "zero"} {
|
||||
record := &model.ClientRecord{Email: prefix + name, UUID: "uuid-" + name, SubID: "sub-" + name, TgID: tgID, Enable: true}
|
||||
if name == "foreign" {
|
||||
record.TgID++
|
||||
}
|
||||
if name == "zero" {
|
||||
record.TgID = 0
|
||||
}
|
||||
if err := db.Create(record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, record.Id)
|
||||
if name == "b" {
|
||||
if err := db.Model(record).Update("enable", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if name != "unbound" {
|
||||
links := []model.ClientInbound{{ClientId: record.Id, InboundId: inbounds[0].Id}, {ClientId: record.Id, InboundId: inbounds[1].Id}}
|
||||
if name == "deleted-inbound" {
|
||||
links = []model.ClientInbound{{ClientId: record.Id, InboundId: -6886}}
|
||||
}
|
||||
if err := db.Create(&links).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if name != "no-traffic" {
|
||||
traffic := &xray.ClientTraffic{Email: record.Email, InboundId: -1, Enable: false, Up: int64(10 + i), Down: 20, Total: 500, ExpiryTime: 12345, Reset: 3, LastOnline: 6789}
|
||||
if err := db.Create(traffic).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
trace := &tgLookupSQL{Interface: logger.Default}
|
||||
previous := db.Logger
|
||||
db.Logger = trace
|
||||
t.Cleanup(func() { db.Logger = previous })
|
||||
svc := &InboundService{}
|
||||
for _, id := range []int64{tgID, tgID + 100, 0, -1} {
|
||||
trace.queries = nil
|
||||
got, err := svc.GetClientTrafficTgBot(id)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup(%d): %v", id, err)
|
||||
}
|
||||
if id != tgID {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("lookup(%d) returned %v", id, got)
|
||||
}
|
||||
} else {
|
||||
var emails []string
|
||||
for _, traffic := range got {
|
||||
emails = append(emails, traffic.Email)
|
||||
}
|
||||
if !reflect.DeepEqual(emails, []string{prefix + "a", prefix + "b"}) {
|
||||
t.Fatalf("emails = %v", emails)
|
||||
}
|
||||
for i, traffic := range got {
|
||||
name := []string{"a", "b"}[i]
|
||||
if traffic.Enable != (i == 0) || traffic.UUID != "uuid-"+name || traffic.SubId != "sub-"+name || traffic.Up != int64(10+i) || traffic.Down != 20 || traffic.Total != 500 || traffic.ExpiryTime != 12345 || traffic.Reset != 3 || traffic.LastOnline != 6789 || traffic.InboundId != -1 {
|
||||
t.Fatalf("incorrect metadata/counters: %+v", traffic)
|
||||
}
|
||||
}
|
||||
}
|
||||
wantQueries := 1
|
||||
if id <= 0 {
|
||||
wantQueries = 0
|
||||
}
|
||||
if len(trace.queries) != wantQueries {
|
||||
t.Fatalf("lookup(%d) executed %d queries, want %d: %v", id, len(trace.queries), wantQueries, trace.queries)
|
||||
}
|
||||
for _, sql := range trace.queries {
|
||||
if strings.Contains(strings.ToLower(sql), "settings") || strings.Contains(strings.ToLower(sql), "json") {
|
||||
t.Fatalf("JSON scan in indexed path: %s", sql)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -1057,72 +1056,37 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClientsTable migrates legacy JSON at startup; normal writes synchronize these
|
||||
// records. Never fall back to settings JSON, which can retain stale ownership.
|
||||
func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffic, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
idQuery := fmt.Sprintf(
|
||||
"SELECT DISTINCT inbounds.id %s WHERE %s = ?",
|
||||
database.JSONClientsFromInbound(),
|
||||
database.JSONFieldText("client.value", "tgId"),
|
||||
)
|
||||
var inboundIds []int
|
||||
if err := db.Raw(idQuery, strconv.FormatInt(tgId, 10)).Scan(&inboundIds).Error; err != nil {
|
||||
logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var inbounds []*model.Inbound
|
||||
if len(inboundIds) > 0 {
|
||||
err := db.Model(model.Inbound{}).Where("id IN ?", inboundIds).Find(&inbounds).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var emails []string
|
||||
for _, inbound := range inbounds {
|
||||
clients, err := s.GetClients(inbound)
|
||||
if err != nil {
|
||||
logger.Errorf("Error retrieving clients for inbound %d: %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.TgID == tgId {
|
||||
emails = append(emails, client.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 errors.Is(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)
|
||||
if tgId <= 0 {
|
||||
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
|
||||
}
|
||||
type row struct {
|
||||
xray.ClientTraffic
|
||||
ClientEnable bool
|
||||
ClientUUID string
|
||||
ClientSubID string
|
||||
}
|
||||
var rows []row
|
||||
err := database.GetDB().Table("clients").
|
||||
Select("client_traffics.*, clients.enable AS client_enable, clients.uuid AS client_uuid, clients.sub_id AS client_sub_id").
|
||||
Joins("JOIN client_traffics ON client_traffics.email = clients.email").
|
||||
Where("clients.tg_id = ?", tgId).
|
||||
Where("EXISTS (SELECT 1 FROM client_inbounds JOIN inbounds ON inbounds.id = client_inbounds.inbound_id WHERE client_inbounds.client_id = clients.id)").
|
||||
Order("clients.id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
traffics := make([]*xray.ClientTraffic, 0, len(rows))
|
||||
for i := range rows {
|
||||
traffic := &rows[i].ClientTraffic
|
||||
traffic.Enable = rows[i].ClientEnable
|
||||
traffic.UUID = rows[i].ClientUUID
|
||||
traffic.SubId = rows[i].ClientSubID
|
||||
traffics = append(traffics, traffic)
|
||||
}
|
||||
|
||||
return traffics, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -586,11 +586,6 @@ func (t *Tgbot) randomLowerAndNum(length int) string {
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
// int64Contains checks if an int64 slice contains a specific item.
|
||||
func int64Contains(slice []int64, item int64) bool {
|
||||
return slices.Contains(slice, item)
|
||||
}
|
||||
|
||||
// isSingleWord checks if the text contains only a single word.
|
||||
func (t *Tgbot) isSingleWord(text string) bool {
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
@@ -19,8 +20,8 @@ const (
|
||||
ownerMail = "owner@x"
|
||||
)
|
||||
|
||||
// newLinksCallbackTgbot seeds one inbound whose settings bind email to
|
||||
// ownerTgID, the traffic row the ownership lookup joins on, and a mocked API.
|
||||
// newLinksCallbackTgbot seeds synchronized client records, inbound settings,
|
||||
// traffic and a mocked Telegram API for the owner's link callbacks.
|
||||
func newLinksCallbackTgbot(t *testing.T, email string) (*Tgbot, func(string) int) {
|
||||
t.Helper()
|
||||
mock, calls := staleButtonServer(t, map[string]any{
|
||||
@@ -47,6 +48,13 @@ func newLinksCallbackTgbot(t *testing.T, email string) (*Tgbot, func(string) int
|
||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
clients, err := (&service.InboundService{}).GetClients(inbound)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (&service.ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.GetDB().Create(&xray.ClientTraffic{
|
||||
InboundId: inbound.Id,
|
||||
Email: email,
|
||||
@@ -101,6 +109,25 @@ func TestClientLinkCallbackServesOwnClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientOwnershipUsesNormalizedTelegramID(t *testing.T) {
|
||||
tb, _ := newLinksCallbackTgbot(t, ownerMail)
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", ownerMail).Update("tg_id", ownerTgID+1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tb.clientOwnedByTgUser(ownerTgID, ownerMail) {
|
||||
t.Fatal("stale settings JSON granted ownership to the previous Telegram user")
|
||||
}
|
||||
if !tb.clientOwnedByTgUser(ownerTgID+1, ownerMail) {
|
||||
t.Fatal("the normalized Telegram owner was denied access")
|
||||
}
|
||||
for _, id := range []int64{0, -1, ownerTgID + 2} {
|
||||
if tb.clientOwnedByTgUser(id, ownerMail) {
|
||||
t.Fatalf("unbound Telegram ID %d received ownership", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test: a payload past 64 chars arrives as its hash, so an email long
|
||||
// enough to be hashed must still be decoded and served to its owner.
|
||||
func TestHashedLinkCallbackServesOwnClient(t *testing.T) {
|
||||
|
||||
@@ -76,7 +76,10 @@ func newInviteTgbot(t *testing.T, email string) (*Tgbot, func(string) int) {
|
||||
Update("settings", `{"clients":[{"id":"6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f","email":"`+email+`","subId":"sub-invite"}]}`).Error; err != nil {
|
||||
t.Fatalf("unbind seeded client: %v", err)
|
||||
}
|
||||
seedClientRecord(t, email, "sub-invite", 0)
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"sub_id": "sub-invite", "tg_id": 0}).Error; err != nil {
|
||||
t.Fatalf("unbind normalized client: %v", err)
|
||||
}
|
||||
withAdmins(t, 1)
|
||||
return tb, calls
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ func withAdmins(t *testing.T, ids ...int64) {
|
||||
func newLevelTgbot(t *testing.T) (*Tgbot, func(string) int) {
|
||||
t.Helper()
|
||||
tb, calls := newLinksCallbackTgbot(t, ownerMail)
|
||||
seedClientRecord(t, ownerMail, "sub-owned", ownerTgID)
|
||||
withAdmins(t, 1)
|
||||
return tb, calls
|
||||
}
|
||||
|
||||
@@ -312,19 +312,23 @@ func (t *Tgbot) notifyExhausted() {
|
||||
logger.Warning("Unable to load Inbounds", err)
|
||||
}
|
||||
|
||||
var chatIDsDone []int64
|
||||
chatIDsDone := make(map[int64]struct{})
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Enable {
|
||||
if len(inbound.ClientStats) > 0 {
|
||||
clients, err := t.inboundService.GetClients(inbound)
|
||||
clients, err := t.inboundService.ListClientsForInbound(inbound.Id)
|
||||
if err == nil {
|
||||
for _, client := range clients {
|
||||
if client.TgID != 0 {
|
||||
chatID := client.TgID
|
||||
if !int64Contains(chatIDsDone, chatID) && !checkAdmin(chatID) {
|
||||
if _, done := chatIDsDone[chatID]; !done && !checkAdmin(chatID) {
|
||||
chatIDsDone[chatID] = struct{}{}
|
||||
var disabledClients []xray.ClientTraffic
|
||||
var exhaustedClients []xray.ClientTraffic
|
||||
traffics, err := t.inboundService.GetClientTrafficTgBot(client.TgID)
|
||||
if err != nil {
|
||||
logger.Warningf("Unable to load Telegram client traffic for %d: %v", chatID, err)
|
||||
}
|
||||
if err == nil && len(traffics) > 0 {
|
||||
var output strings.Builder
|
||||
output.WriteString(t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients")))
|
||||
@@ -357,7 +361,6 @@ func (t *Tgbot) notifyExhausted() {
|
||||
}
|
||||
t.SendMsgToTgbot(chatID, output.String())
|
||||
}
|
||||
chatIDsDone = append(chatIDsDone, chatID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,13 +11,16 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||
"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"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
)
|
||||
|
||||
// seedReportClients writes one inbound holding every email plus the traffic row
|
||||
@@ -185,3 +189,92 @@ func TestTrafficUsageReportEscapesHtml(t *testing.T) {
|
||||
t.Errorf("report text = %q, want the remark escaped as <fast>", got[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyExhaustedLooksUpEachIDOnce(t *testing.T) {
|
||||
for _, outcome := range []string{"empty", "failure", "exhausted", "expiry", "admin", "zero", "disabled-inbound"} {
|
||||
t.Run(outcome, func(t *testing.T) {
|
||||
tb := initReportDB(t)
|
||||
clientInfoLocalizer(t)
|
||||
mock, sent := captureReportServer(t)
|
||||
swapTestBot(t, mock.URL)
|
||||
t.Cleanup(mock.Close)
|
||||
clients := []model.Client{
|
||||
{Email: "notify-a", ID: "uuid-a", TgID: 6886, Enable: true},
|
||||
{Email: "notify-b", ID: "uuid-b", TgID: 6886, Enable: true},
|
||||
}
|
||||
if outcome == "zero" {
|
||||
for i := range clients {
|
||||
clients[i].TgID = 0
|
||||
}
|
||||
}
|
||||
if outcome == "admin" {
|
||||
tgBotMutex.Lock()
|
||||
previous := adminIds
|
||||
adminIds = append(append([]int64(nil), adminIds...), 6886)
|
||||
tgBotMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
tgBotMutex.Lock()
|
||||
adminIds = previous
|
||||
tgBotMutex.Unlock()
|
||||
})
|
||||
}
|
||||
db := database.GetDB()
|
||||
for i := range 2 {
|
||||
inbound := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("notify-%d", i), Port: 41890 + i, Enable: true, Protocol: model.VLESS, Settings: `{"clients":[{"email":"notify-a","id":"uuid-a","enable":true,"tgId":6886},{"email":"notify-b","id":"uuid-b","enable":true,"tgId":6886}]}`}
|
||||
if outcome == "disabled-inbound" {
|
||||
inbound.Enable = false
|
||||
}
|
||||
if err := db.Create(inbound).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (&service.ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Keep the inbound eligible even when the Telegram account has no traffic.
|
||||
traffic := &xray.ClientTraffic{Email: fmt.Sprintf("unrelated-%d", i), InboundId: inbound.Id}
|
||||
if outcome == "exhausted" || outcome == "expiry" {
|
||||
traffic.Email = clients[i].Email
|
||||
traffic.Enable = true
|
||||
traffic.Total = 1
|
||||
traffic.Up = 2
|
||||
if outcome == "expiry" {
|
||||
traffic.Total = 0
|
||||
traffic.ExpiryTime = time.Now().Add(-time.Hour).UnixMilli()
|
||||
}
|
||||
}
|
||||
if err := db.Create(traffic).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
lookups := 0
|
||||
cb := "test:notify_lookup"
|
||||
if err := db.Callback().Query().Before("gorm:query").Register(cb, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "clients" && strings.Contains(fmt.Sprint(tx.Statement.Clauses["WHERE"]), "tg_id") {
|
||||
lookups++
|
||||
if outcome == "failure" {
|
||||
tx.AddError(errors.New("injected Telegram lookup failure"))
|
||||
}
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Query().Remove(cb) })
|
||||
tb.notifyExhausted()
|
||||
wantLookups := 1
|
||||
if outcome == "admin" || outcome == "zero" || outcome == "disabled-inbound" {
|
||||
wantLookups = 0
|
||||
}
|
||||
if lookups != wantLookups {
|
||||
t.Fatalf("lookups = %d, want %d after %s", lookups, wantLookups, outcome)
|
||||
}
|
||||
got := sent()
|
||||
if outcome == "exhausted" || outcome == "expiry" {
|
||||
if len(got) != 1 || !strings.Contains(got[0].Text, "notify-a") || !strings.Contains(got[0].Text, "notify-b") {
|
||||
t.Fatalf("want one notification containing both clients, got %+v", got)
|
||||
}
|
||||
} else if len(got) != 0 {
|
||||
t.Fatalf("unexpected notifications: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user