package tgbot import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "slices" "strconv" "strings" "time" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/mymmrac/telego" tu "github.com/mymmrac/telego/telegoutil" "github.com/skip2/go-qrcode" ) // BuildClientDraftMessage builds a protocol-neutral summary of the in-progress // client (email, attached inbounds, traffic limit, expiry, ip limit, comment) // shown in the multi-inbound add flow. Per-protocol secrets (UUID, password, // flow, method) are generated by fillProtocolDefaults on submit, so the bot // never has to track them per inbound itself. func (t *Tgbot) BuildClientDraftMessage() string { now := time.Now().UnixMilli() expiry := "" switch { case client_ExpiryTime == 0: expiry = t.I18nBot("tgbot.unlimited") case client_ExpiryTime < 0: expiry = fmt.Sprintf("%d %s", client_ExpiryTime/-86400000, t.I18nBot("tgbot.days")) default: diff := client_ExpiryTime - now if diff > 172800000 { expiry = time.UnixMilli(client_ExpiryTime).Format("2006-01-02 15:04:05") } else { expiry = fmt.Sprintf("%d %s", diff/3600000, t.I18nBot("tgbot.hours")) } } traffic := "♾️ Unlimited(Reset)" if client_TotalGB > 0 { traffic = common.FormatTraffic(client_TotalGB) } ipLimit := "♾️ Unlimited(Reset)" if client_LimitIP > 0 { ipLimit = fmt.Sprint(client_LimitIP) } attached := t.describeAttachedInbounds(receiver_inbound_IDs) if attached == "" { attached = "—" } comment := client_Comment if comment == "" { comment = "—" } tgID := client_TgID if tgID == "" { tgID = "—" } var b strings.Builder b.WriteString("📝 *New client draft*\r\n") b.WriteString(fmt.Sprintf("📧 Email: `%s`\r\n", client_Email)) b.WriteString(fmt.Sprintf("🔗 Attached: %s\r\n", attached)) b.WriteString(fmt.Sprintf("📊 Traffic: %s\r\n", traffic)) b.WriteString(fmt.Sprintf("📅 Expire: %s\r\n", expiry)) b.WriteString(fmt.Sprintf("🔢 IP limit: %s\r\n", ipLimit)) b.WriteString(fmt.Sprintf("👤 TG user: %s\r\n", tgID)) b.WriteString(fmt.Sprintf("💬 Comment: %s\r\n", comment)) return b.String() } // describeAttachedInbounds returns a short "remark1, remark2" list for the given // inbound ids, falling back to "#id" when an inbound can't be loaded. func (t *Tgbot) describeAttachedInbounds(ids []int) string { if len(ids) == 0 { return "" } parts := make([]string, 0, len(ids)) for _, id := range ids { ib, err := t.inboundService.GetInbound(id) if err != nil || ib == nil { parts = append(parts, fmt.Sprintf("#%d", id)) continue } label := ib.Remark if label == "" { label = fmt.Sprintf("#%d", id) } parts = append(parts, label) } return strings.Join(parts, ", ") } // SubmitAddClient sends the in-progress client to ClientService.Create with // the full set of attached inbound ids. Per-inbound fillProtocolDefaults on // the panel generates UUID/password/auth per protocol, so the bot only // supplies the universal fields it actually collected. func (t *Tgbot) SubmitAddClient() (bool, error) { inboundIDs := receiver_inbound_IDs if len(inboundIDs) == 0 && receiver_inbound_ID > 0 { inboundIDs = []int{receiver_inbound_ID} } if len(inboundIDs) == 0 { return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) } tgIDInt, _ := strconv.ParseInt(client_TgID, 10, 64) client := model.Client{ Email: client_Email, Enable: client_Enable, LimitIP: client_LimitIP, TotalGB: client_TotalGB, ExpiryTime: client_ExpiryTime, SubID: client_SubID, Comment: client_Comment, Reset: client_Reset, TgID: tgIDInt, } return t.clientService.Create(&t.inboundService, &service.ClientCreatePayload{ Client: client, InboundIds: inboundIDs, }) } // 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 subURI, _ := t.settingService.GetSubURI() subJsonURI, _ := t.settingService.GetSubJsonURI() subDomain, _ := t.settingService.GetSubDomain() subPort, _ := t.settingService.GetSubPort() subPath, _ := t.settingService.GetSubPath() subJsonPath, _ := t.settingService.GetSubJsonPath() subJsonEnable, _ := t.settingService.GetSubJsonEnable() 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 + "/" } var subURL string var subJsonURL string // If pre-configured URIs are available, use them directly if subURI != "" { if !strings.HasSuffix(subURI, "/") { subURI = subURI + "/" } subURL = fmt.Sprintf("%s%s", subURI, client.SubID) } else { subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID) } if subJsonURI != "" { if !strings.HasSuffix(subJsonURI, "/") { subJsonURI = subJsonURI + "/" } subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID) } else { subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID) } if !subJsonEnable { subJsonURL = "" } return subURL, subJsonURL, nil } // sendClientSubLinks sends the subscription links for the client to the chat. 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 + "" if subJsonURL != "" { msg += "\r\n\r\nJSON URL:\r\n" + subJsonURL + "" } inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_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 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 { 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] var msg strings.Builder msg.WriteString(t.I18nBot("subscription.individualLinks")) msg.WriteString(":\r\n") for _, link := range chunk { // wrap each link in msg.WriteString("") msg.WriteString(link) msg.WriteString("\r\n") } t.SendMsgToTgbot(chatId, msg.String()) } } // 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 for client "+email+":") // 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) when available if subJsonURL != "" { 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 := 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) } } } } } } } // clientInfoMsg formats client information message based on traffic and flags. func (t *Tgbot) clientInfoMsg( traffic *xray.ClientTraffic, printEnabled bool, printOnline bool, printActive bool, printDate bool, printTraffic bool, printRefreshed bool, ) string { now := time.Now().Unix() expiryTime := "" flag := false diff := traffic.ExpiryTime/1000 - now if traffic.ExpiryTime == 0 { expiryTime = t.I18nBot("tgbot.unlimited") } else if diff > 172800 || !traffic.Enable { expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05") if diff > 0 { days := diff / 86400 hours := (diff % 86400) / 3600 minutes := (diff % 3600) / 60 remainingTime := "" if days > 0 { remainingTime += fmt.Sprintf("%d %s ", days, t.I18nBot("tgbot.days")) } if hours > 0 { remainingTime += fmt.Sprintf("%d %s ", hours, t.I18nBot("tgbot.hours")) } if minutes > 0 { remainingTime += fmt.Sprintf("%d %s", minutes, t.I18nBot("tgbot.minutes")) } expiryTime += fmt.Sprintf(" (%s)", remainingTime) } } else if traffic.ExpiryTime < 0 { expiryTime = fmt.Sprintf("%d %s", traffic.ExpiryTime/-86400000, t.I18nBot("tgbot.days")) flag = true } else { expiryTime = fmt.Sprintf("%d %s", diff/3600, t.I18nBot("tgbot.hours")) flag = true } total := "" if traffic.Total == 0 { total = t.I18nBot("tgbot.unlimited") } else { total = common.FormatTraffic((traffic.Total)) } enabled := "" isEnabled, err := t.clientService.CheckIsEnabledByEmail(&t.inboundService, traffic.Email) if err != nil { logger.Warning(err) enabled = t.I18nBot("tgbot.wentWrong") } else if isEnabled { enabled = t.I18nBot("tgbot.messages.yes") } else { enabled = t.I18nBot("tgbot.messages.no") } active := "" if traffic.Enable { active = t.I18nBot("tgbot.messages.yes") } else { active = t.I18nBot("tgbot.messages.no") } status := t.I18nBot("tgbot.offline") isOnline := false if service.XrayProcess().IsRunning() { if slices.Contains(service.XrayProcess().GetOnlineClients(), traffic.Email) { status = t.I18nBot("tgbot.online") isOnline = true } } output := "" output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email) if attachIds, err := t.clientService.GetInboundIdsForEmail(nil, traffic.Email); err == nil && len(attachIds) > 0 { output += fmt.Sprintf("🔗 Inbounds: %s\r\n", t.describeAttachedInbounds(attachIds)) } if printEnabled { output += t.I18nBot("tgbot.messages.enabled", "Enable=="+enabled) } if printOnline { output += t.I18nBot("tgbot.messages.online", "Status=="+status) if !isOnline && traffic.LastOnline > 0 { output += t.I18nBot("tgbot.messages.lastOnline", "Time=="+time.UnixMilli(traffic.LastOnline).Format("2006-01-02 15:04:05")) } } if printActive { output += t.I18nBot("tgbot.messages.active", "Enable=="+active) } if printDate { if flag { output += t.I18nBot("tgbot.messages.expireIn", "Time=="+expiryTime) } else { output += t.I18nBot("tgbot.messages.expire", "Time=="+expiryTime) } } if printTraffic { output += t.I18nBot("tgbot.messages.upload", "Upload=="+common.FormatTraffic(traffic.Up)) output += t.I18nBot("tgbot.messages.download", "Download=="+common.FormatTraffic(traffic.Down)) output += t.I18nBot("tgbot.messages.total", "UpDown=="+common.FormatTraffic((traffic.Up+traffic.Down)), "Total=="+total) } if printRefreshed { output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) } return output } // getClientUsage retrieves and sends client usage information to the chat. func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) { traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) if err != nil { logger.Warning(err) msg := t.I18nBot("tgbot.wentWrong") t.SendMsgToTgbot(chatId, msg) return } if len(traffics) == 0 { t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) return } output := "" if len(traffics) > 0 { if len(email) > 0 { for _, traffic := range traffics { if traffic.Email == email[0] { output := t.clientInfoMsg(traffic, true, true, true, true, true, true) t.SendMsgToTgbot(chatId, output) return } } msg := t.I18nBot("tgbot.noResult") t.SendMsgToTgbot(chatId, msg) return } else { for _, traffic := range traffics { output += t.clientInfoMsg(traffic, true, true, true, true, true, false) output += "\r\n" } } } output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) t.SendMsgToTgbot(chatId, output) output = t.I18nBot("tgbot.commands.pleaseChoose") t.SendAnswer(chatId, output, false) } // searchClientIps searches and sends client IP addresses for the given email. func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) { ips, err := t.inboundService.GetInboundClientIps(email) if err != nil || len(ips) == 0 { ips = t.I18nBot("tgbot.noIpRecord") } formattedIps := ips if err == nil && len(ips) > 0 { type ipWithTimestamp struct { IP string `json:"ip"` Timestamp int64 `json:"timestamp"` } var ipsWithTime []ipWithTimestamp if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 { lines := make([]string, 0, len(ipsWithTime)) for _, item := range ipsWithTime { if item.IP == "" { continue } if item.Timestamp > 0 { ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05") lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts)) continue } lines = append(lines, item.IP) } if len(lines) > 0 { formattedIps = strings.Join(lines, "\n") } } else { var oldIps []string if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 { formattedIps = strings.Join(oldIps, "\n") } } } output := "" output += t.I18nBot("tgbot.messages.email", "Email=="+email) output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps) output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("ips_refresh "+email)), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clearIPs")).WithCallbackData(t.encodeQuery("clear_ips "+email)), ), ) if len(messageID) > 0 { t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard) } else { t.SendMsgToTgbot(chatId, output, inlineKeyboard) } } // clientTelegramUserInfo retrieves and sends Telegram user info for the client. func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ...int) { traffic, client, err := t.inboundService.GetClientByEmail(email) if err != nil { logger.Warning(err) msg := t.I18nBot("tgbot.wentWrong") t.SendMsgToTgbot(chatId, msg) return } if client == nil { msg := t.I18nBot("tgbot.noResult") t.SendMsgToTgbot(chatId, msg) return } tgId := "None" if client.TgID != 0 { tgId = strconv.FormatInt(client.TgID, 10) } output := "" output += t.I18nBot("tgbot.messages.email", "Email=="+email) output += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgId) output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("tgid_refresh "+email)), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.removeTGUser")).WithCallbackData(t.encodeQuery("tgid_remove "+email)), ), ) if len(messageID) > 0 { t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard) } else { t.SendMsgToTgbot(chatId, output, inlineKeyboard) requestUser := telego.KeyboardButtonRequestUsers{ RequestID: int32(traffic.Id), UserIsBot: new(bool), } keyboard := tu.Keyboard( tu.KeyboardRow( tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&requestUser), ), tu.KeyboardRow( tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard")), ), ).WithIsPersistent().WithResizeKeyboard() t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.buttons.selectOneTGUser"), keyboard) } } // searchClient searches for a client by email and sends the information. func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) { traffic, err := t.inboundService.GetClientTrafficByEmail(email) if err != nil { logger.Warning(err) msg := t.I18nBot("tgbot.wentWrong") t.SendMsgToTgbot(chatId, msg) return } if traffic == nil { msg := t.I18nBot("tgbot.noResult") t.SendMsgToTgbot(chatId, msg) return } output := t.clientInfoMsg(traffic, true, true, true, true, true, true) inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("client_refresh "+email)), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic "+email)), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData(t.encodeQuery("limit_traffic "+email)), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData(t.encodeQuery("reset_exp "+email)), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLog")).WithCallbackData(t.encodeQuery("ip_log "+email)), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData(t.encodeQuery("ip_limit "+email)), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData(t.encodeQuery("tg_user "+email)), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.toggle")).WithCallbackData(t.encodeQuery("toggle_enable "+email)), ), ) if len(messageID) > 0 { t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard) } else { t.SendMsgToTgbot(chatId, output, inlineKeyboard) } } // getCommonClientButtons returns the shared inline keyboard rows for the // client-first multi-inbound add flow. Per-protocol secrets (UUID, password, // flow, method) are generated by fillProtocolDefaults on submit, so the bot // only exposes the universal client fields here. func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton { attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(receiver_inbound_IDs)) return [][]telego.InlineKeyboardButton{ tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData("add_client_ch_default_tg_id"), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(attachLabel).WithCallbackData("add_client_attach_more"), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"), ), } } // addClient renders the draft message + shared client-first keyboard. func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) { inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons()...) if len(messageID) > 0 { t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard) } else { t.SendMsgToTgbot(chatId, msg, inlineKeyboard) } }