diff --git a/internal/web/service/tgbot/tgbot.go b/internal/web/service/tgbot/tgbot.go index 4f6c509a0..4304c60cd 100644 --- a/internal/web/service/tgbot/tgbot.go +++ b/internal/web/service/tgbot/tgbot.go @@ -5,7 +5,6 @@ import ( "crypto/rand" "embed" "math/big" - "net/http" "net/url" "os" "regexp" @@ -48,8 +47,7 @@ var ( EventBus *eventbus.Bus // Performance improvements - messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing - optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts + messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing // Simple cache for frequently accessed data statusCache struct { @@ -306,17 +304,6 @@ func (t *Tgbot) Start(i18nFS embed.FS) error { // Initialize worker pool for concurrent message processing (max 10 concurrent handlers) messageWorkerPool = make(chan struct{}, 10) - // Initialize optimized HTTP client with connection pooling - optimizedHTTPClient = &http.Client{ - Timeout: 15 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 30 * time.Second, - DisableKeepAlives: false, - }, - } - t.SetHostname() // Get Telegram bot token diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go index 1278cca47..952482d30 100644 --- a/internal/web/service/tgbot/tgbot_client.go +++ b/internal/web/service/tgbot/tgbot_client.go @@ -2,13 +2,11 @@ package tgbot import ( "context" - "encoding/base64" "encoding/json" "errors" "fmt" "html" - "io" - "net/http" + "net/url" "slices" "strconv" "strings" @@ -248,76 +246,42 @@ func (t *Tgbot) sendClientSubLinks(chatId int64, email string) { t.SendMsgToTgbot(chatId, msg, inlineKeyboard) } -// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user +// clientSubLinks builds the subscription's links in-process for the host subURL +// names; fetching subURL instead fails whenever that host does not resolve here. +func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) { + u, err := url.Parse(subURL) + if err != nil { + return nil, err + } + _, client, err := t.inboundService.GetClientByEmail(email) + if err != nil || client == nil { + return nil, errors.New("client not found") + } + return t.inboundService.GetSubLinks(u.Hostname(), client.SubID) +} + +// sendClientIndividualLinks sends the subscription's individual links to the user func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) { - // Build the HTML sub page URL; we'll call it with header Accept to get raw content subURL, _, err := t.buildSubscriptionURLs(email) if err != nil { t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) return } - - // Try to fetch raw subscription links. Prefer plain text response. - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subURL, nil) + links, err := t.clientSubLinks(email, subURL) if err != nil { t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) return } - // Force plain text to avoid HTML page; controller respects Accept header - req.Header.Set("Accept", "text/plain, */*;q=0.1") - - // Use optimized client with connection pooling - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - req = req.WithContext(ctx) - - resp, err := optimizedHTTPClient.Do(req) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - defer resp.Body.Close() - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - - // If service is configured to encode (Base64), decode it - encoded, _ := t.settingService.GetSubEncrypt() - var content string - if encoded { - decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes)) - if err != nil { - // fallback to raw text - content = string(bodyBytes) - } else { - content = string(decoded) - } - } else { - content = string(bodyBytes) - } - - // Normalize line endings and trim - lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") - var cleaned []string - for _, l := range lines { - l = strings.TrimSpace(l) - if l != "" { - cleaned = append(cleaned, l) - } - } - if len(cleaned) == 0 { + if len(links) == 0 { t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult")) return } // Send in chunks to respect message length; use monospace formatting const maxPerMessage = 50 - for i := 0; i < len(cleaned); i += maxPerMessage { - j := min(i+maxPerMessage, len(cleaned)) - chunk := cleaned[i:j] + for i := 0; i < len(links); i += maxPerMessage { + j := min(i+maxPerMessage, len(links)) + chunk := links[i:j] var msg strings.Builder msg.WriteString(t.I18nBot("subscription.individualLinks")) msg.WriteString(":\r\n") @@ -375,51 +339,20 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) { } // Also generate a few individual links' QRs (first up to 5) - subPageURL := subURL - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subPageURL, nil) - if err == nil { - req.Header.Set("Accept", "text/plain, */*;q=0.1") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - req = req.WithContext(ctx) - if resp, err := optimizedHTTPClient.Do(req); err == nil { - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - encoded, _ := t.settingService.GetSubEncrypt() - var content string - if encoded { - if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil { - content = string(dec) - } else { - content = string(body) - } - } else { - content = string(body) - } - lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") - var cleaned []string - for _, l := range lines { - l = strings.TrimSpace(l) - if l != "" { - cleaned = append(cleaned, l) - } - } - if len(cleaned) > 0 { - max := min(len(cleaned), 5) - for i := range max { - if png, err := createQR(cleaned[i], 320); err == nil { - // Use the email as filename for individual link QR - filename := email + ".png" - document := tu.Document( - tu.ID(chatId), - tu.FileFromBytes(png, filename), - ) - _, _ = bot.SendDocument(context.Background(), document) - // Reduced delay for better performance - if i < max-1 { // Only delay between documents, not after the last one - time.Sleep(50 * time.Millisecond) - } - } + if links, err := t.clientSubLinks(email, subURL); err == nil { + max := min(len(links), 5) + for i := range max { + if png, err := createQR(links[i], 320); err == nil { + // Use the email as filename for individual link QR + filename := email + ".png" + document := tu.Document( + tu.ID(chatId), + tu.FileFromBytes(png, filename), + ) + _, _ = bot.SendDocument(context.Background(), document) + // Reduced delay for better performance + if i < max-1 { // Only delay between documents, not after the last one + time.Sleep(50 * time.Millisecond) } } } diff --git a/internal/web/service/tgbot/tgbot_individual_links_test.go b/internal/web/service/tgbot/tgbot_individual_links_test.go new file mode 100644 index 000000000..f6706d086 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_individual_links_test.go @@ -0,0 +1,89 @@ +package tgbot + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + + "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/sub" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// recordingBotServer answers sendMessage and records every text it was asked to send. +func recordingBotServer(t *testing.T) func() []string { + t.Helper() + var mu sync.Mutex + var texts []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/bot"+testBotToken+"/sendMessage" { + w.WriteHeader(http.StatusNotFound) + return + } + var body struct { + Text string `json:"text"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + texts = append(texts, body.Text) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "message_id": 1, "date": 0, "chat": map[string]any{"id": ownerTgID, "type": "private"}, + }}) + })) + t.Cleanup(srv.Close) + swapTestBot(t, srv.URL) + return func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), texts...) + } +} + +// With no sub or web domain set, the only host the bot knows is the machine +// name, which need not resolve; the links must not depend on reaching it. +func TestIndividualLinksDoNotNeedAResolvableHost(t *testing.T) { + sent := recordingBotServer(t) + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + service.RegisterSubLinkProvider(sub.NewLinkProvider()) + + const uuid = "11111111-2222-4333-8444-555555555555" + db := database.GetDB() + ib := &model.Inbound{ + UserId: 1, Tag: "in-443", Enable: true, Listen: "203.0.113.5", Port: 443, + Protocol: model.VLESS, Remark: "in", + Settings: `{"clients":[{"id":"` + uuid + `","email":"` + ownerMail + `","tgId":4242,"subId":"sub-owned","enable":true}],"decryption":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + rec := &model.ClientRecord{Email: ownerMail, SubID: "sub-owned", UUID: uuid, TgID: ownerTgID, Enable: true} + if err := db.Create(rec).Error; err != nil { + t.Fatalf("seed client: %v", err) + } + if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil { + t.Fatalf("seed client_inbound: %v", err) + } + if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: ownerMail, Enable: true}).Error; err != nil { + t.Fatalf("seed traffic: %v", err) + } + + origHost, origRunning := hostname, isRunning + t.Cleanup(func() { hostname, isRunning = origHost, origRunning }) + hostname, isRunning = "unresolvable-panel-host.invalid", true + + tapClientLinks(t, &Tgbot{}, ownerTgID, "client_individual_links "+ownerMail) + + got := strings.Join(sent(), "\n") + if !strings.Contains(got, "vless://"+uuid+"@203.0.113.5:443") { + t.Fatalf("bot sent %q, want the client's vless link", got) + } +}