From ed96fa090bc936fcf134e9337a185150a8d4dcf3 Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 14 Sep 2025 19:51:57 +0200 Subject: [PATCH] tgbot: subscription,qrcode, link --- go.mod | 1 + go.sum | 2 + web/service/tgbot.go | 343 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+) diff --git a/go.mod b/go.mod index 26f45c61..78b6f09c 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 github.com/robfig/cron/v3 v3.0.1 github.com/shirou/gopsutil/v4 v4.25.8 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/valyala/fasthttp v1.65.0 github.com/xlzd/gotp v0.1.0 github.com/xtls/xray-core v1.250911.0 diff --git a/go.sum b/go.sum index e45b4986..7cf8d118 100644 --- a/go.sum +++ b/go.sum @@ -142,6 +142,8 @@ github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 h1:emzAzMZ1 github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771/go.mod h1:bR6DqgcAl1zTcOX8/pE2Qkj9XO00eCNqmKb7lXP8EAg= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/web/service/tgbot.go b/web/service/tgbot.go index e0acebb7..aab0d3e8 100644 --- a/web/service/tgbot.go +++ b/web/service/tgbot.go @@ -7,8 +7,10 @@ import ( "encoding/base64" "errors" "fmt" + "io" "math/big" "net" + "net/http" "net/url" "os" "regexp" @@ -29,6 +31,7 @@ import ( "github.com/mymmrac/telego" th "github.com/mymmrac/telego/telegohandler" tu "github.com/mymmrac/telego/telegoutil" + "github.com/skip2/go-qrcode" "github.com/valyala/fasthttp" "github.com/valyala/fasthttp/fasthttpproxy" ) @@ -1355,6 +1358,73 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool case "client_commands": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands")) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpClientCommands")) + case "client_sub_links": + // show user's own clients to choose one for sub links + tgUserID := callbackQuery.From.ID + traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) + if err != nil { + // fallback to message + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + if len(traffics) == 0 { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) + return + } + var buttons []telego.InlineKeyboardButton + for _, tr := range traffics { + buttons = append(buttons, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_sub_links "+tr.Email))) + } + cols := 1 + if len(buttons) >= 6 { + cols = 2 + } + keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard) + case "client_individual_links": + // show user's clients to choose for individual links + tgUserID := callbackQuery.From.ID + traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + if len(traffics) == 0 { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) + return + } + var buttons2 []telego.InlineKeyboardButton + for _, tr := range traffics { + buttons2 = append(buttons2, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_individual_links "+tr.Email))) + } + cols2 := 1 + if len(buttons2) >= 6 { + cols2 = 2 + } + keyboard2 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols2, buttons2...)) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard2) + case "client_qr_links": + // show user's clients to choose for QR codes + tgUserID := callbackQuery.From.ID + traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOccurred")+"\r\n"+err.Error()) + return + } + if len(traffics) == 0 { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) + return + } + var buttons3 []telego.InlineKeyboardButton + for _, tr := range traffics { + buttons3 = append(buttons3, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_qr_links "+tr.Email))) + } + cols3 := 1 + if len(buttons3) >= 6 { + cols3 = 2 + } + keyboard3 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols3, buttons3...)) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard3) case "onlines": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.onlines")) t.onlineClients(chatId) @@ -1439,6 +1509,23 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool ) prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment) t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) + default: + // dynamic callbacks + if strings.HasPrefix(callbackQuery.Data, "client_sub_links ") { + email := strings.TrimPrefix(callbackQuery.Data, "client_sub_links ") + t.sendClientSubLinks(chatId, email) + return + } + if strings.HasPrefix(callbackQuery.Data, "client_individual_links ") { + email := strings.TrimPrefix(callbackQuery.Data, "client_individual_links ") + t.sendClientIndividualLinks(chatId, email) + return + } + if strings.HasPrefix(callbackQuery.Data, "client_qr_links ") { + email := strings.TrimPrefix(callbackQuery.Data, "client_qr_links ") + t.sendClientQRLinks(chatId, email) + return + } case "add_client_ch_default_traffic": inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( @@ -1847,6 +1934,13 @@ func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) { tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")), ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")), + tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")), + ), ) var ReplyMarkup telego.ReplyMarkup @@ -1908,6 +2002,255 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R } } +// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email +func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) { + // Resolve subId from client email + traffic, client, err := t.inboundService.GetClientByEmail(email) + _ = traffic + if err != nil || client == nil { + return "", "", errors.New("client not found") + } + + // Gather settings to construct absolute URLs + subDomain, _ := t.settingService.GetSubDomain() + subPort, _ := t.settingService.GetSubPort() + subPath, _ := t.settingService.GetSubPath() + subJsonPath, _ := t.settingService.GetSubJsonPath() + subKeyFile, _ := t.settingService.GetSubKeyFile() + subCertFile, _ := t.settingService.GetSubCertFile() + + tls := (subKeyFile != "" && subCertFile != "") + scheme := "http" + if tls { + scheme = "https" + } + + // Fallbacks + if subDomain == "" { + // try panel domain, otherwise OS hostname + if d, err := t.settingService.GetWebDomain(); err == nil && d != "" { + subDomain = d + } else if hostname != "" { + subDomain = hostname + } else { + subDomain = "localhost" + } + } + + host := subDomain + if (subPort == 443 && tls) || (subPort == 80 && !tls) { + // standard ports: no port in host + } else { + host = fmt.Sprintf("%s:%d", subDomain, subPort) + } + + // Ensure paths + if !strings.HasPrefix(subPath, "/") { + subPath = "/" + subPath + } + if !strings.HasSuffix(subPath, "/") { + subPath = subPath + "/" + } + if !strings.HasPrefix(subJsonPath, "/") { + subJsonPath = "/" + subJsonPath + } + if !strings.HasSuffix(subJsonPath, "/") { + subJsonPath = subJsonPath + "/" + } + + subURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID) + subJsonURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID) + return subURL, subJsonURL, nil +} + +func (t *Tgbot) sendClientSubLinks(chatId int64, email string) { + subURL, subJsonURL, err := t.buildSubscriptionURLs(email) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + msg := "Subscription URL:\r\n" + subURL + "\r\n\r\n" + + "JSON URL:\r\n" + subJsonURL + "" + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links " + email)), + ), + ) + t.SendMsgToTgbot(chatId, msg, inlineKeyboard) +} + +// sendClientIndividualLinks fetches the subscription content (individual links) and sends it 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.NewRequest("GET", subURL, nil) + 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 default client with reasonable timeout via context + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + req = req.WithContext(ctx) + + resp, err := http.DefaultClient.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 { + 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 := i + maxPerMessage + if j > len(cleaned) { + j = len(cleaned) + } + chunk := cleaned[i:j] + msg := t.I18nBot("subscription.individualLinks") + ":\r\n" + for _, link := range chunk { + // wrap each link in + msg += "" + link + "\r\n" + } + t.SendMsgToTgbot(chatId, msg) + } +} + +// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them +func (t *Tgbot) sendClientQRLinks(chatId int64, email string) { + subURL, subJsonURL, err := t.buildSubscriptionURLs(email) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + + // Helper to create QR PNG bytes from content + createQR := func(content string, size int) ([]byte, error) { + if size <= 0 { + size = 256 + } + return qrcode.Encode(content, qrcode.Medium, size) + } + + // Inform user + t.SendMsgToTgbot(chatId, "QRCode"+":") + + // Send sub URL QR (filename: sub.png) + if png, err := createQR(subURL, 320); err == nil { + document := tu.Document( + tu.ID(chatId), + tu.FileFromBytes(png, "sub.png"), + ) + _, _ = bot.SendDocument(context.Background(), document) + } else { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + } + + // Send JSON URL QR (filename: subjson.png) + if png, err := createQR(subJsonURL, 320); err == nil { + document := tu.Document( + tu.ID(chatId), + tu.FileFromBytes(png, "subjson.png"), + ) + _, _ = bot.SendDocument(context.Background(), document) + } else { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + } + + // Also generate a few individual links' QRs (first up to 5) + subPageURL := subURL + req, err := http.NewRequest("GET", 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 := http.DefaultClient.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) + time.Sleep(200 * time.Millisecond) + } + } + } + } + } +} + func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) { if len(replyMarkup) > 0 { for _, adminId := range adminIds {