package tgbot import ( "context" "encoding/json" "fmt" "html" "strconv" "strings" "time" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mymmrac/telego" tu "github.com/mymmrac/telego/telegoutil" "github.com/skip2/go-qrcode" ) func qrPNG(content string, size int) ([]byte, error) { if size <= 0 { size = 256 } return qrcode.Encode(content, qrcode.Medium, size) } // The client card and its screens (limits, expiry, IP log, TG binding, links, // QR) all redraw the same tracked message, so none leaves the previous behind. // clientScreenBody is the shared header of every per-client screen: a user who // tapped through four levels still sees which client they are on. func (t *Tgbot) clientScreenBody(email, title string) string { body := "" + html.EscapeString(email) + "\n" if title != "" { body += html.EscapeString(title) + "\n\n" } return body } // screenClientIps draws the IP log with refresh and clear. func (t *Tgbot) screenClientIps(chatID int64, email string) { body := t.clientScreenBody(email, "") ips, err := t.inboundService.GetInboundClientIps(email) if err != nil || len(ips) == 0 { body += t.I18nBot("tgbot.noIpRecord") } else { body += t.I18nBot("tgbot.messages.ips", "IPs=="+html.EscapeString(formatIPLog(ips))) } body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) rows := [][]telego.InlineKeyboardButton{ tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", t.encodeQuery("ips_refresh "+email))), tu.InlineKeyboardRow(t.btn("tgbot.buttons.clearIPs", t.encodeQuery("clear_ips "+email))), tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))), t.backRow(), } t.renderScreen(chatID, t.newScreen("client", body, rows...)) } // formatIPLog renders the stored IP log, which is either a list of addresses or // a list of {ip, timestamp} objects depending on when it was written. func formatIPLog(raw string) string { type ipWithTimestamp struct { IP string `json:"ip"` Timestamp int64 `json:"timestamp"` } var entries []ipWithTimestamp if json.Unmarshal([]byte(raw), &entries) == nil && len(entries) > 0 { lines := make([]string, 0, len(entries)) for _, item := range entries { if item.IP == "" { continue } if item.Timestamp > 0 { lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05"))) continue } lines = append(lines, item.IP) } if len(lines) > 0 { return strings.Join(lines, "\n") } } var plain []string if json.Unmarshal([]byte(raw), &plain) == nil && len(plain) > 0 { return strings.Join(plain, "\n") } return raw } // screenClientTG draws a client's Telegram binding. The user picker is the one // reply keyboard kept: the API has no inline way to request a user. func (t *Tgbot) screenClientTG(chatID int64, email string, withPicker bool) { traffic, client, err := t.inboundService.GetClientByEmail(email) if err != nil || client == nil { t.sendNotice(chatID, t.I18nBot("tgbot.noResult")) return } tgID := t.I18nBot("tgbot.noValue") if client.TgID != 0 { tgID = strconv.FormatInt(client.TgID, 10) } body := t.clientScreenBody(email, "") body += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgID) body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) rows := [][]telego.InlineKeyboardButton{} if withPicker { rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.selectTGUser", t.encodeQuery("tgid_pick "+email)))) } rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", t.encodeQuery("tgid_refresh "+email))), tu.InlineKeyboardRow(t.btn("tgbot.buttons.removeTGUser", t.encodeQuery("tgid_remove "+email))), tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))), t.backRow(), ) t.renderScreen(chatID, t.newScreen("client", body, rows...)) if withPicker { t.sendTGPicker(chatID, traffic.Id) } } // sendTGPicker posts the native user picker — the only way the API asks for a // user. It is a real message, so it carries the hide button and is removed later. func (t *Tgbot) sendTGPicker(chatID int64, trafficID int) { if liveBot() == nil { return } t.sendNotice(chatID, t.I18nBot("tgbot.buttons.selectOneTGUser")) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() request := telego.KeyboardButtonRequestUsers{RequestID: int32(trafficID), UserIsBot: new(bool)} keyboard := tu.Keyboard( tu.KeyboardRow(tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&request)), tu.KeyboardRow(tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard"))), ).WithIsPersistent().WithResizeKeyboard() _, _ = liveBot().SendMessage(ctx, &telego.SendMessageParams{ ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: t.I18nBot("tgbot.buttons.selectOneTGUser"), ReplyMarkup: keyboard, }) } // ownSubscriptionOnly reports whether the screen is the client's own single // subscription, where admin-only controls must stay off (reached via "Links"). func (t *Tgbot) ownSubscriptionOnly(chatID int64, email string) bool { if checkAdmin(chatID) { return false } traffics, err := t.inboundService.GetClientTrafficTgBot(chatID) if err != nil { return false } return len(traffics) == 1 && traffics[0].Email == email } // screenClientLinks draws the subscription and the links of a client. func (t *Tgbot) screenClientLinks(chatID int64, email string) { subURL, subJSON, err := t.buildSubscriptionURLs(email) if err != nil { t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) return } body := t.clientScreenBody(email, "") body += t.I18nBot("tgbot.messages.subURL", "URL=="+html.EscapeString(subURL)) if subJSON != "" { body += t.I18nBot("tgbot.messages.jsonURL", "URL=="+html.EscapeString(subJSON)) } body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) rows := [][]telego.InlineKeyboardButton{ tu.InlineKeyboardRow(t.btn("tgbot.buttons.individualLinks", t.encodeQuery("client_individual_links "+email))), tu.InlineKeyboardRow(t.btn("tgbot.buttons.qrCode", t.encodeQuery("client_qr_links "+email))), } // A client looking at its own subscription has nothing behind the admin's // card: it only repeats the usage already on its home screen. if !t.ownSubscriptionOnly(chatID, email) { rows = append(rows, tu.InlineKeyboardRow( t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email)))) } rows = append(rows, t.backRow()) t.renderScreen(chatID, t.newScreen("links", body, rows...)) } // screenIndividualLinks renders the per-app links of the subscription as one // (paged) message instead of one message per app. func (t *Tgbot) screenIndividualLinks(chatID int64, email string) { subURL, _, err := t.buildSubscriptionURLs(email) if err != nil { t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) return } links, err := t.clientSubLinks(email, subURL) if err != nil || len(links) == 0 { t.sendNotice(chatID, t.I18nBot("tgbot.noResult")) return } var body strings.Builder body.WriteString(t.clientScreenBody(email, t.I18nBot("tgbot.buttons.individualLinks"))) for _, link := range links { body.WriteString("" + html.EscapeString(link) + "\n") } t.renderScreen(chatID, t.newScreen("links", body.String(), t.individualLinksRows()...)) } // individualLinksRows is the individual-links screen's keyboard: exactly one // control, the shared back row, since a second "Links" button was a duplicate. func (t *Tgbot) individualLinksRows() [][]telego.InlineKeyboardButton { return [][]telego.InlineKeyboardButton{t.backRow()} } // screenClientQR renders the subscription QR as the screen picture and leaves // the individual QRs to a document, so one screen covers the tap. func (t *Tgbot) screenClientQR(chatID int64, email string) { subURL, _, err := t.buildSubscriptionURLs(email) if err != nil { t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) return } body := t.clientScreenBody(email, t.I18nBot("tgbot.answers.qrCodeForClient", "Email=="+email)) body += t.I18nBot("tgbot.messages.subURL", "URL=="+html.EscapeString(subURL)) rows := [][]telego.InlineKeyboardButton{ tu.InlineKeyboardRow(t.btn("tgbot.buttons.individualLinks", t.encodeQuery("client_individual_links "+email))), t.backRow(), } // One picture carrying every code the bot once sent as separate documents; // tiles carry no text (they are scanned), so the caption legend numbers them. codes := t.subscriptionQRCodes(email, subURL) if len(codes) == 0 { t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) return } sc := t.newScreen("qr", body+t.qrCodeLegend(codes), rows...) sc.qrPicture = qrSheetPicture(codes) sc.qrSource = subURL if sc.qrPicture == nil { t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) return } t.renderScreen(chatID, sc) } // renderPickerOnClient redraws the client card with a picker's rows appended, so // tapping a limit opens its value list on the same tracked screen. func (t *Tgbot) renderPickerOnClient(chatID int64, email string, picker [][]telego.InlineKeyboardButton) { traffic, err := t.inboundService.GetClientTrafficByEmail(email) if err != nil || traffic == nil { t.sendNotice(chatID, t.I18nBot("tgbot.noResult")) return } rows := append([][]telego.InlineKeyboardButton{}, picker...) rows = append(rows, t.backRow()) body := t.clientScreenBody(email, t.clientInfoMsg(traffic, true, true, true, true, true, false)) t.renderScreen(chatID, t.newScreen("client", body, rows...)) } // renderDraftPicker redraws the add-client draft with a value picker's rows // appended: the wizard's own card stays visible while a value is chosen. func (t *Tgbot) renderDraftPicker(chatID int64, draft *clientDraft, picker [][]telego.InlineKeyboardButton) { rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...) rows = append(rows, picker...) t.renderScreen(chatID, t.newScreen("wizard", t.BuildClientDraftMessage(draft), rows...)) } // adoptScreen marks the message the user is looking at as this chat's screen, // so the next render edits it instead of stacking a second screen under it. func (t *Tgbot) adoptScreen(chatID int64, msgID int) { if msgID == 0 { return } if _, ok := t.screens().get(chatID); ok { return } t.screens().put(chatID, &screen{msgID: msgID}) } // clientTrafficID resolves the traffic row id a client's TG-user picker needs. // A missing row is 0: the caller's screen already reports the unknown client. func (t *Tgbot) clientTrafficID(email string) int { traffic, err := t.inboundService.GetClientTrafficByEmail(email) if err != nil || traffic == nil { return 0 } return traffic.Id } // sendNoticeNoKeyboard reports a result and drops the client picker's reply // keyboard in one message: this is the only place it is torn down. func (t *Tgbot) sendNoticeNoKeyboard(chatID int64, text string) { if liveBot() == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() _, err := liveBot().SendMessage(ctx, &telego.SendMessageParams{ ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: text, ParseMode: "HTML", ReplyMarkup: tu.ReplyKeyboardRemove(), }) if err != nil { logger.Warning("Failed to send a notice:", err) } } // wizardPrompt asks for a text value on the draft screen itself: the wizard owns // one message, so the question replaces the card; the reply keyboard is untouched. func (t *Tgbot) wizardPrompt(chatID int64, draft *clientDraft, prompt string) { rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...) rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.use_default", "add_client_default_info"))) t.renderScreen(chatID, t.newScreen("wizard", prompt, rows...)) } // wizardInvalidInput redraws the draft with a rejection hint: the wizard keeps // ONE message, so a retry does not stack a hint under a stale card. func (t *Tgbot) wizardInvalidInput(chatID int64, draft *clientDraft) { rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...) rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.use_default", "add_client_default_info"))) t.renderScreen(chatID, t.newScreen("wizard", t.BuildClientDraftMessage(draft)+"\n\n"+t.I18nBot("tgbot.messages.incorrect_input"), rows...)) } // screenInviteLink draws the invite link of a client as instructions plus the // link itself, so an admin can forward it without leaving the screen. func (t *Tgbot) screenInviteLink(chatID int64, email string) { record, err := t.clientService.GetRecordByEmail(nil, email) username := botUsername() if err != nil || record.SubID == "" || username == "" { logger.Warning("tgbot: invite link unavailable for", email, err) t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) return } payload, ok := encodeInvitePayload(record.SubID) if !ok { logger.Warning("tgbot: subId of", email, "is too long for a Telegram invite link") t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) return } link := "https://t.me/" + username + "?start=" + payload body := t.clientScreenBody(email, t.I18nBot("tgbot.buttons.inviteLink")) body += "" + html.EscapeString(link) + "\n\n" body += t.I18nBot("tgbot.messages.inviteHint") rows := [][]telego.InlineKeyboardButton{ tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))), t.backRow(), } t.renderScreen(chatID, t.newScreen("client", body, rows...)) } // openOwnClientScreen resolves the client behind a Telegram user and opens the // asked-for screen; several subscriptions become one button each. func (t *Tgbot) openOwnClientScreen(chatID, tgUserID int64, action string) { traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) if err != nil { t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) return } if len(traffics) == 0 { t.sendNotice(chatID, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) return } if len(traffics) == 1 { t.openClientAction(chatID, action, traffics[0].Email) return } var buttons []telego.InlineKeyboardButton for _, tr := range traffics { buttons = append(buttons, tu.InlineKeyboardButton(tr.Email). WithCallbackData(t.encodeQuery(action+" "+tr.Email))) } cols := 1 if len(buttons) >= 6 { cols = 2 } keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) rows := append(keyboard.InlineKeyboard, t.backRow()) t.renderScreen(chatID, t.newScreen("links", t.I18nBot("tgbot.commands.pleaseChoose"), rows...)) } // openClientAction opens one per-client screen by name. func (t *Tgbot) openClientAction(chatID int64, action, email string) { switch action { case "client_sub_links": t.screenClientLinks(chatID, email) case "client_individual_links": t.screenIndividualLinks(chatID, email) case "client_qr_links": t.screenClientQR(chatID, email) } }