mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 03:35:04 +03:00
a712bf1413
pg:next, pg:prev and pg:none were missing from isClientSelfCallback, so the callback gate dropped every page tap from a non-admin and a client with several accounts was stuck on page one. Paging only re-renders the screen stored for the caller's own chat, so the gate now lets it through. An account's links screen also shows that account's usage to its owner instead of the admin-only client card button. Fixes #7032.
393 lines
15 KiB
Go
393 lines
15 KiB
Go
package tgbot
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"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 := "<b>" + html.EscapeString(email) + "</b>\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=="+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,
|
|
})
|
|
}
|
|
|
|
// ownClientTraffic returns the usage of a client the chat's user owns, and nil
|
|
// for an admin: the owner sees its usage here, the admin has the client card.
|
|
func (t *Tgbot) ownClientTraffic(chatID int64, email string) *xray.ClientTraffic {
|
|
if checkAdmin(chatID) {
|
|
return nil
|
|
}
|
|
traffics, err := t.inboundService.GetClientTrafficTgBot(chatID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
for _, traffic := range traffics {
|
|
if traffic.Email == email {
|
|
return traffic
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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, "")
|
|
own := t.ownClientTraffic(chatID, email)
|
|
if own != nil {
|
|
body = t.clientInfoMsg(own, true, true, true, true, true, false) + "\n"
|
|
}
|
|
body += t.I18nBot("tgbot.messages.subURL", "URL=="+subURL)
|
|
if subJSON != "" {
|
|
body += t.I18nBot("tgbot.messages.jsonURL", "URL=="+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))),
|
|
}
|
|
// The client card is admin-only; a client's own usage is already above.
|
|
if own == nil {
|
|
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("<code>" + html.EscapeString(link) + "</code>\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=="+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 += "<code>" + html.EscapeString(link) + "</code>\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)
|
|
}
|
|
}
|