diff --git a/docs/content/docs/en/operations/telegram-bot.mdx b/docs/content/docs/en/operations/telegram-bot.mdx index 283adbdb5..40acd2324 100644 --- a/docs/content/docs/en/operations/telegram-bot.mdx +++ b/docs/content/docs/en/operations/telegram-bot.mdx @@ -76,8 +76,11 @@ admins are notified when one runs out. Admins also get inline-button flows for server usage, sorted traffic reports, resetting traffic, DB backups, ban logs, listing inbounds/clients, online -clients, "depleting soon", and a full **add-client** wizard. Regular users get -buttons for their own usage, subscription links, individual links, and QR codes. +clients, "depleting soon", and a full **add-client** wizard. The admin's +**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 diff --git a/docs/content/docs/fa/operations/telegram-bot.mdx b/docs/content/docs/fa/operations/telegram-bot.mdx index c900e056a..b8c3f5417 100644 --- a/docs/content/docs/fa/operations/telegram-bot.mdx +++ b/docs/content/docs/fa/operations/telegram-bot.mdx @@ -77,6 +77,8 @@ icon: Send ادمین‌ها همچنین جریان‌های دکمه‌ی درون‌خطی برای مصرف سرور، گزارش‌های ترافیک مرتب‌شده، بازنشانی ترافیک، پشتیبان‌گیری از DB، گزارش‌های مسدودسازی، فهرست کردن ورودی‌ها/کلاینت‌ها، کلاینت‌های آنلاین، «به‌زودی تمام‌شونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند. +کارت **کلاینت** در اختیار ادمین است و لینک اشتراک، لینک‌های منفرد و کد QR کلاینت را +نشان می‌دهد، تا کانفیگ را بتوان به کلاینتی که هنوز به Telegram دسترسی ندارد هم داد. کاربران عادی دکمه‌هایی برای مصرف خودشان، لینک‌های اشتراک، لینک‌های منفرد و کدهای QR دارند. ## گزارش‌ها و هشدارها diff --git a/docs/content/docs/ru/operations/telegram-bot.mdx b/docs/content/docs/ru/operations/telegram-bot.mdx index f321843c0..87b5ad4f6 100644 --- a/docs/content/docs/ru/operations/telegram-bot.mdx +++ b/docs/content/docs/ru/operations/telegram-bot.mdx @@ -80,9 +80,11 @@ ID темы после двоеточия, например `-1001234567890:42`. Администраторам также доступны сценарии со встроенными кнопками: использование сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД, журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты, -«скоро исчерпают» и полноценный мастер **добавления клиента**. Обычным -пользователям доступны кнопки для своего расхода, ссылок на подписку, отдельных -ссылок и QR-кодов. +«скоро исчерпают» и полноценный мастер **добавления клиента**. В карточке +клиента администратор получает ссылку на подписку, отдельные ссылки и QR-код — +конфигурацию можно передать клиенту, который ещё не может открыть Telegram. +Обычным пользователям доступны кнопки для своего расхода, ссылок на подписку, +отдельных ссылок и QR-кодов. ## Отчёты и оповещения diff --git a/docs/content/docs/zh/operations/telegram-bot.mdx b/docs/content/docs/zh/operations/telegram-bot.mdx index 2b9887cfd..02f7f2c13 100644 --- a/docs/content/docs/zh/operations/telegram-bot.mdx +++ b/docs/content/docs/zh/operations/telegram-bot.mdx @@ -71,7 +71,9 @@ ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长 管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、 重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、 -“即将耗尽”,以及完整的**添加客户端**向导。普通用户则可以使用按钮查看 +“即将耗尽”,以及完整的**添加客户端**向导。管理员的**客户端卡片**上还带有该客户端的 +订阅链接、单条链接和 QR 码,因此可以把配置交给暂时还无法打开 Telegram 的客户端。 +普通用户则可以使用按钮查看 自己的用量、订阅链接、单条链接和 QR 码。 ## 报告与告警 diff --git a/internal/web/service/tgbot/screen_legacy.go b/internal/web/service/tgbot/screen_legacy.go index 4067c4ec3..9bc2ccbea 100644 --- a/internal/web/service/tgbot/screen_legacy.go +++ b/internal/web/service/tgbot/screen_legacy.go @@ -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.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))), t.backRow(), } diff --git a/internal/web/service/tgbot/screens_test.go b/internal/web/service/tgbot/screens_test.go index 3e5b3296d..8775554ed 100644 --- a/internal/web/service/tgbot/screens_test.go +++ b/internal/web/service/tgbot/screens_test.go @@ -1166,3 +1166,145 @@ func TestLinksScreenShowsUsageToItsOwner(t *testing.T) { 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") + } +}