fix(tgbot): restore the admin's client link and QR buttons (#6986)

The v3.11.0 bot rebuild left the admin's client card without the subscription link, individual links and QR code, so an admin could not hand a config to a client who cannot reach Telegram yet. The card now carries Links and QR code buttons that open the existing screens. Closes #6984.
This commit is contained in:
25f2002654
2026-11-03 20:22:35 +05:30
committed by GitHub
parent 19f2f21d81
commit 83ea59fb69
6 changed files with 163 additions and 6 deletions
@@ -76,8 +76,11 @@ admins are notified when one runs out.
Admins also get inline-button flows for server usage, sorted traffic reports, Admins also get inline-button flows for server usage, sorted traffic reports,
resetting traffic, DB backups, ban logs, listing inbounds/clients, online resetting traffic, DB backups, ban logs, listing inbounds/clients, online
clients, "depleting soon", and a full **add-client** wizard. Regular users get clients, "depleting soon", and a full **add-client** wizard. The admin's
buttons for their own usage, subscription links, individual links, and QR codes. **client card** carries the client's subscription link, individual links and QR
code, so a config can be handed to a client who cannot reach Telegram yet.
Regular users get buttons for their own usage, subscription links, individual
links, and QR codes.
## Reports & alerts ## Reports & alerts
@@ -77,6 +77,8 @@ icon: Send
ادمین‌ها همچنین جریان‌های دکمه‌ی درون‌خطی برای مصرف سرور، گزارش‌های ترافیک مرتب‌شده، ادمین‌ها همچنین جریان‌های دکمه‌ی درون‌خطی برای مصرف سرور، گزارش‌های ترافیک مرتب‌شده،
بازنشانی ترافیک، پشتیبان‌گیری از DB، گزارش‌های مسدودسازی، فهرست کردن ورودی‌ها/کلاینت‌ها، بازنشانی ترافیک، پشتیبان‌گیری از DB، گزارش‌های مسدودسازی، فهرست کردن ورودی‌ها/کلاینت‌ها،
کلاینت‌های آنلاین، «به‌زودی تمام‌شونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند. کلاینت‌های آنلاین، «به‌زودی تمام‌شونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند.
کارت **کلاینت** در اختیار ادمین است و لینک اشتراک، لینک‌های منفرد و کد QR کلاینت را
نشان می‌دهد، تا کانفیگ را بتوان به کلاینتی که هنوز به Telegram دسترسی ندارد هم داد.
کاربران عادی دکمه‌هایی برای مصرف خودشان، لینک‌های اشتراک، لینک‌های منفرد و کدهای QR دارند. کاربران عادی دکمه‌هایی برای مصرف خودشان، لینک‌های اشتراک، لینک‌های منفرد و کدهای QR دارند.
## گزارش‌ها و هشدارها ## گزارش‌ها و هشدارها
@@ -80,9 +80,11 @@ ID темы после двоеточия, например `-1001234567890:42`.
Администраторам также доступны сценарии со встроенными кнопками: использование Администраторам также доступны сценарии со встроенными кнопками: использование
сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД, сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД,
журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты, журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты,
«скоро исчерпают» и полноценный мастер **добавления клиента**. Обычным «скоро исчерпают» и полноценный мастер **добавления клиента**. В карточке
пользователям доступны кнопки для своего расхода, ссылок на подписку, отдельных клиента администратор получает ссылку на подписку, отдельные ссылки и QR-код —
ссылок и QR-кодов. конфигурацию можно передать клиенту, который ещё не может открыть Telegram.
Обычным пользователям доступны кнопки для своего расхода, ссылок на подписку,
отдельных ссылок и QR-кодов.
## Отчёты и оповещения ## Отчёты и оповещения
@@ -71,7 +71,9 @@ ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长
管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、 管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、
重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、 重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、
“即将耗尽”,以及完整的**添加客户端**向导。普通用户则可以使用按钮查看 “即将耗尽”,以及完整的**添加客户端**向导。管理员的**客户端卡片**上还带有该客户端的
订阅链接、单条链接和 QR 码,因此可以把配置交给暂时还无法打开 Telegram 的客户端。
普通用户则可以使用按钮查看
自己的用量、订阅链接、单条链接和 QR 码。 自己的用量、订阅链接、单条链接和 QR 码。
## 报告与告警 ## 报告与告警
@@ -140,6 +140,12 @@ func (t *Tgbot) clientRows(email string) [][]telego.InlineKeyboardButton {
t.btn("tgbot.buttons.setTGUser", t.encodeQuery("tg_user "+email)), t.btn("tgbot.buttons.setTGUser", t.encodeQuery("tg_user "+email)),
t.btn("tgbot.buttons.inviteLink", t.encodeQuery("client_invite_link "+email)), t.btn("tgbot.buttons.inviteLink", t.encodeQuery("client_invite_link "+email)),
), ),
// An invite link needs the client to reach Telegram; a subscription URL
// and a QR code do not, so they are the admin's way to hand one over.
tu.InlineKeyboardRow(
t.btn("tgbot.buttons.links", t.encodeQuery("client_sub_links "+email)),
t.btn("tgbot.buttons.qrCode", t.encodeQuery("client_qr_links "+email)),
),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.toggle", t.encodeQuery("toggle_enable "+email))), tu.InlineKeyboardRow(t.btn("tgbot.buttons.toggle", t.encodeQuery("toggle_enable "+email))),
t.backRow(), t.backRow(),
} }
+142
View File
@@ -1166,3 +1166,145 @@ func TestLinksScreenShowsUsageToItsOwner(t *testing.T) {
t.Errorf("a client is offered the admin's client card: %s", markup) t.Errorf("a client is offered the admin's client card: %s", markup)
} }
} }
const (
adminTgID = int64(31337)
adminCardEmail = "admin-card@example.com"
)
// seedAdminClient stores one client the way the panel keeps it: an inbound row
// with its settings blob, a traffic row and a client record.
func seedAdminClient(t *testing.T, email string) {
t.Helper()
inbound := &model.Inbound{
UserId: 1, Tag: "in-" + email, Remark: "in-" + email, Port: 8443,
Protocol: model.VLESS, Enable: true,
Settings: `{"clients":[{"email":"` + email + `","enable":true,"subId":"sub-` + email + `"}]}`,
}
if err := database.GetDB().Create(inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
if err := database.GetDB().Create(&xray.ClientTraffic{
InboundId: inbound.Id, Email: email, Enable: true,
}).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
record := (&model.Client{Email: email, Enable: true, SubID: "sub-" + email}).ToRecord()
if err := database.GetDB().Create(record).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := database.GetDB().Create(&model.ClientInbound{ClientId: record.Id, InboundId: inbound.Id}).Error; err != nil {
t.Fatalf("seed client_inbounds: %v", err)
}
}
// cardCallback finds the callback data of the card's button for one action.
func cardCallback(t *testing.T, sc *screen, action string) string {
t.Helper()
for _, row := range sc.rows {
for _, btn := range row {
if strings.HasPrefix(btn.CallbackData, action+" ") {
return btn.CallbackData
}
}
}
t.Fatalf("the client card offers no %q button: %+v", action, sc.rows)
return ""
}
// The v3.11.0 rebuild left the admin's client card with no way to the subscription link or
// QR code, so an admin could not hand a config to a client who cannot reach Telegram (#6984).
func TestAdminClientCardOffersTheLinksAndQRCode(t *testing.T) {
tb, _ := newScreenTgbot(t, false)
initReportDB(t)
setTestAdmins(t, adminTgID)
seedAdminClient(t, adminCardEmail)
tb.searchClientScreen(adminTgID, adminCardEmail)
sc, ok := tb.screens().get(adminTgID)
if !ok {
t.Fatal("no screen was tracked")
}
markup, _ := json.Marshal(sc.markup)
for _, want := range []string{"client_sub_links " + adminCardEmail, "client_qr_links " + adminCardEmail} {
if !strings.Contains(string(markup), want) {
t.Errorf("the admin's client card lacks %q: %s", want, markup)
}
}
}
// The button has to work, not just exist: tapping it must draw the subscription screen,
// since a URL the admin can read is the whole point of the flow.
func TestAdminTappingTheClientCardLinksDrawsTheSubscription(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
initReportDB(t)
localizeWithRealBundle(t)
setTestAdmins(t, adminTgID)
seedAdminClient(t, adminCardEmail)
tb.searchClientScreen(adminTgID, adminCardEmail)
sc, ok := tb.screens().get(adminTgID)
if !ok {
t.Fatal("no screen was tracked")
}
data := cardCallback(t, sc, "client_sub_links")
tb.answerCallback(&telego.CallbackQuery{
ID: "q1",
From: telego.User{ID: adminTgID},
Data: data,
Message: &telego.Message{Chat: telego.Chat{ID: adminTgID}},
}, true)
links, ok := tb.screens().get(adminTgID)
if !ok {
t.Fatal("no screen was tracked")
}
if links.kind != "links" {
t.Fatalf("screen kind = %q, want the links screen", links.kind)
}
if !strings.Contains(links.text, "sub-") {
t.Errorf("the links screen carries no subscription url: %q", links.text)
}
if countMethod(calls(), "editMessageMedia")+countMethod(calls(), "editMessageText") == 0 {
t.Error("the links screen was never drawn")
}
}
// The QR code is the second half of the request: an admin sends a picture
// straight to a client, so the card must reach it too.
func TestAdminTappingTheClientCardQRCodeDrawsTheCode(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
initReportDB(t)
setTestAdmins(t, adminTgID)
seedAdminClient(t, adminCardEmail)
tb.searchClientScreen(adminTgID, adminCardEmail)
sc, ok := tb.screens().get(adminTgID)
if !ok {
t.Fatal("no screen was tracked")
}
data := cardCallback(t, sc, "client_qr_links")
tb.answerCallback(&telego.CallbackQuery{
ID: "q1",
From: telego.User{ID: adminTgID},
Data: data,
Message: &telego.Message{Chat: telego.Chat{ID: adminTgID}},
}, true)
qr, ok := tb.screens().get(adminTgID)
if !ok {
t.Fatal("no screen was tracked")
}
if qr.kind != "qr" {
t.Fatalf("screen kind = %q, want the qr screen", qr.kind)
}
if qr.qrPicture == nil {
t.Error("the qr screen carries no sheet of codes")
}
if countMethod(calls(), "sendPhoto") == 0 && countMethod(calls(), "editMessageMedia") == 0 {
t.Error("the qr screen was never drawn")
}
}