From a712bf14135fc31a14fa9b426d9a7072e74fbe29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=B0=D1=82-=D0=9E=D1=80=D1=88=D0=B8=D1=85?= <34618353+orshih6@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:27:40 +0800 Subject: [PATCH] fix(tgbot): let clients page their screens and see usage per account (#7033) 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. --- .../web/service/tgbot/screen_client_views.go | 27 ++++++--- internal/web/service/tgbot/screens_test.go | 55 +++++++++++++++++++ internal/web/service/tgbot/tgbot_router.go | 3 + 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/internal/web/service/tgbot/screen_client_views.go b/internal/web/service/tgbot/screen_client_views.go index 9a2136a2d..438e36a1c 100644 --- a/internal/web/service/tgbot/screen_client_views.go +++ b/internal/web/service/tgbot/screen_client_views.go @@ -10,6 +10,7 @@ import ( "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" @@ -137,17 +138,22 @@ func (t *Tgbot) sendTGPicker(chatID int64, trafficID int) { }) } -// 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 { +// 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 false + return nil } traffics, err := t.inboundService.GetClientTrafficTgBot(chatID) if err != nil { - return false + return nil } - return len(traffics) == 1 && traffics[0].Email == email + for _, traffic := range traffics { + if traffic.Email == email { + return traffic + } + } + return nil } // screenClientLinks draws the subscription and the links of a client. @@ -158,6 +164,10 @@ func (t *Tgbot) screenClientLinks(chatID int64, email string) { 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) @@ -167,9 +177,8 @@ func (t *Tgbot) screenClientLinks(chatID int64, email string) { 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) { + // 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)))) } diff --git a/internal/web/service/tgbot/screens_test.go b/internal/web/service/tgbot/screens_test.go index 048c0de4d..9c45b237e 100644 --- a/internal/web/service/tgbot/screens_test.go +++ b/internal/web/service/tgbot/screens_test.go @@ -18,6 +18,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/web/locale" "github.com/mhsanaei/3x-ui/v3/internal/xray" @@ -1142,3 +1143,57 @@ func TestIndividualLinksScreenHasNoDuplicateLinksButton(t *testing.T) { t.Errorf("the only control is %q, want the back row %q", got, cbHome) } } + +// The pager was drawn on a client's screen but its taps never got past the +// callback gate, so a client with several accounts was stuck on page one. +func TestClientCanTurnPages(t *testing.T) { + tb, _ := newScreenTgbot(t, false) + initReportDB(t) + setTestAdmins(t) + seedClientForTgUser(t, 4848, "pager@x") + + for _, data := range []string{cbPageNext, cbPagePrev, cbPageNone} { + if !isClientSelfCallback(data) { + t.Errorf("a client's %q button is refused by the callback gate", data) + } + } + + tb.renderScreen(4848, tb.newScreen("main", strings.Repeat("line of text\n", 300))) + tb.answerCallback(&telego.CallbackQuery{ + ID: "q1", + From: telego.User{ID: 4848}, + Data: cbPageNext, + Message: &telego.Message{Chat: telego.Chat{ID: 4848}}, + }, false) + + sc, ok := tb.screens().get(4848) + if !ok { + t.Fatal("no screen was tracked") + } + if sc.page != 1 { + t.Errorf("page = %d after a client tapped next, want 1", sc.page) + } +} + +// A client with several accounts opens one from its home screen: that screen +// must show the account's usage, since the client card is admin-only. +func TestLinksScreenShowsUsageToItsOwner(t *testing.T) { + tb, _ := newScreenTgbot(t, false) + initReportDB(t) + setTestAdmins(t) + seedClientForTgUser(t, 4949, "first@x") + seedClientForTgUser(t, 4949, "second@x") + + tb.screenClientLinks(4949, "second@x") + sc, ok := tb.screens().get(4949) + if !ok { + t.Fatal("no screen was tracked") + } + if want := tb.I18nBot("tgbot.messages.upload", "Upload=="+common.FormatTraffic(0)); !strings.Contains(sc.body, want) { + t.Errorf("the client's links screen shows no usage:\n%s", sc.body) + } + markup, _ := json.Marshal(sc.markup) + if strings.Contains(string(markup), "client_get_usage") { + t.Errorf("a client is offered the admin's client card: %s", markup) + } +} diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go index 206ca5c39..b91178473 100644 --- a/internal/web/service/tgbot/tgbot_router.go +++ b/internal/web/service/tgbot/tgbot_router.go @@ -1241,6 +1241,9 @@ func isClientSelfCallback(data string) bool { case "home", "hide", "client_traffic", "client_commands", "client_sub_links", "client_individual_links", "client_qr_links": return true + // Paging only turns the screen already tracked for the caller's own chat. + case cbPageNext, cbPagePrev, cbPageNone: + return true } _, _, ok := splitClientLinkCallback(data) return ok