mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
f1e1b0fb0c
InitLocalizer filled the shared bundle in place, and the Telegram bot runs it again while the panel is serving, so a request could read the bundle's maps while they were being written and the process died on a concurrent map access. Build the bundle privately and publish it, and the bot and web localizers, through atomic pointers; the lazy load in the middleware publishes only if nothing else did first.
1117 lines
35 KiB
Go
1117 lines
35 KiB
Go
package tgbot
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image/png"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
|
"golang.org/x/text/language"
|
|
|
|
"github.com/mymmrac/telego"
|
|
tu "github.com/mymmrac/telego/telegoutil"
|
|
)
|
|
|
|
// recordingServer answers the Bot API calls the screen layer makes and keeps
|
|
// them in order, so a test can assert on the sequence, not just the outcome.
|
|
type apiCall struct {
|
|
Method string
|
|
Payload map[string]any
|
|
}
|
|
|
|
func (c apiCall) text() string {
|
|
if v, ok := c.Payload["text"].(string); ok {
|
|
return v
|
|
}
|
|
if v, ok := c.Payload["caption"].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func recordingServer(t *testing.T, failEdit bool) (*httptest.Server, func() []apiCall) {
|
|
t.Helper()
|
|
var mu sync.Mutex
|
|
var calls []apiCall
|
|
nextID := 100
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
|
var payload map[string]any
|
|
_ = json.Unmarshal(body, &payload)
|
|
if strings.HasPrefix(string(body), "{") {
|
|
// JSON payload: keep it as decoded.
|
|
} else {
|
|
payload = map[string]any{}
|
|
}
|
|
mu.Lock()
|
|
calls = append(calls, apiCall{Method: method, Payload: payload})
|
|
nextID++
|
|
id := nextID
|
|
mu.Unlock()
|
|
|
|
fail := func(desc string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error_code": 400, "description": desc})
|
|
}
|
|
switch method {
|
|
case "editMessageMedia", "editMessageText", "editMessageCaption":
|
|
if failEdit {
|
|
fail("Bad Request: message to edit not found")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
|
"message_id": id, "date": 0, "chat": map[string]any{"id": 1, "type": "private"},
|
|
"photo": []any{map[string]any{"file_id": "ph", "file_unique_id": "u", "width": 1, "height": 1}},
|
|
}})
|
|
case "sendPhoto", "sendMessage", "sendDocument":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
|
"message_id": id, "date": 0, "chat": map[string]any{"id": 1, "type": "private"},
|
|
}})
|
|
case "copyMessage":
|
|
// Telegram answers with a MessageID object, not a boolean.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
|
"message_id": id,
|
|
}})
|
|
case "copyMessages":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": []any{
|
|
map[string]any{"message_id": id},
|
|
}})
|
|
case "getMe":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
|
"id": 42, "is_bot": true, "first_name": "bot", "username": "bot",
|
|
}})
|
|
case "getChat":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
|
"id": 42, "type": "private", "photo": map[string]any{"small_file_id": "s", "big_file_id": "big"},
|
|
}})
|
|
case "getUserProfilePhotos":
|
|
// sendPhoto rejects a getChat ChatPhoto id ("can't use file of type ChatPhoto as Photo"), so the
|
|
// avatar must arrive as a Photo file_id.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
|
"total_count": 1,
|
|
"photos": []any{[]any{
|
|
map[string]any{"file_id": "small-photo", "file_unique_id": "u1", "width": 160, "height": 160},
|
|
map[string]any{"file_id": "big-photo", "file_unique_id": "u2", "width": 640, "height": 640},
|
|
}},
|
|
}})
|
|
default:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": true})
|
|
}
|
|
}))
|
|
return srv, func() []apiCall {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]apiCall(nil), calls...)
|
|
}
|
|
}
|
|
|
|
// newScreenTgbot points the package bot at a recording server and clears the
|
|
// process-wide screen state the previous test may have left behind.
|
|
func newScreenTgbot(t *testing.T, failEdit bool) (*Tgbot, func() []apiCall) {
|
|
t.Helper()
|
|
srv, calls := recordingServer(t, failEdit)
|
|
swapTestBot(t, srv.URL)
|
|
t.Cleanup(srv.Close)
|
|
resetScreenArt()
|
|
return &Tgbot{}, calls
|
|
}
|
|
|
|
func methods(calls []apiCall) []string {
|
|
out := make([]string, 0, len(calls))
|
|
for _, c := range calls {
|
|
out = append(out, c.Method)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func indexOf(calls []apiCall, method string) int {
|
|
for i, c := range calls {
|
|
if c.Method == method {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func TestScreenRendersOnceAndEditsAfterwards(t *testing.T) {
|
|
tb, calls := newScreenTgbot(t, false)
|
|
|
|
tb.renderScreen(1, tb.newScreen("main", "first", tb.homeRows()...))
|
|
tb.renderScreen(1, tb.newScreen("main", "second", tb.homeRows()...))
|
|
|
|
got := calls()
|
|
if n := countMethod(got, "sendPhoto") + countMethod(got, "sendMessage"); n != 1 {
|
|
t.Fatalf("screen messages = %d, want 1: one screen per chat (calls: %v)", n, methods(got))
|
|
}
|
|
if n := countMethod(got, "editMessageMedia"); n != 1 {
|
|
t.Fatalf("editMessageMedia = %d, want 1: the second render must edit (calls: %v)", n, methods(got))
|
|
}
|
|
if n := countMethod(got, "deleteMessage"); n != 0 {
|
|
t.Errorf("deleteMessage = %d, want 0: an edit needs no replacement", n)
|
|
}
|
|
}
|
|
|
|
func TestScreenReplacesInSendThenDeleteOrder(t *testing.T) {
|
|
tb, calls := newScreenTgbot(t, true)
|
|
|
|
tb.renderScreen(1, tb.newScreen("main", "live", tb.homeRows()...))
|
|
before := len(calls())
|
|
tb.renderScreen(1, tb.newScreen("main", "refreshed", tb.homeRows()...))
|
|
|
|
got := calls()[before:]
|
|
send := indexOf(got, "sendPhoto")
|
|
if send < 0 {
|
|
send = indexOf(got, "sendMessage")
|
|
}
|
|
del := indexOf(got, "deleteMessage")
|
|
if send < 0 || del < 0 {
|
|
t.Fatalf("calls = %v, want a re-send and a delete", methods(got))
|
|
}
|
|
if del < send {
|
|
t.Errorf("calls = %v, want the new screen sent BEFORE the old one is deleted", methods(got))
|
|
}
|
|
}
|
|
|
|
func TestScreenTreatsNotModifiedAsSuccess(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var calls []apiCall
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
|
mu.Lock()
|
|
calls = append(calls, apiCall{Method: method})
|
|
mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch method {
|
|
case "editMessageMedia", "editMessageText":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"ok": false, "error_code": 400,
|
|
"description": "Bad Request: message is not modified",
|
|
})
|
|
default:
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
|
"message_id": 7, "date": 0, "chat": map[string]any{"id": 1, "type": "private"},
|
|
}})
|
|
}
|
|
}))
|
|
swapTestBot(t, srv.URL)
|
|
defer srv.Close()
|
|
resetScreenArt()
|
|
|
|
tb := &Tgbot{}
|
|
tb.renderScreen(1, tb.newScreen("main", "one", tb.homeRows()...))
|
|
mu.Lock()
|
|
before := len(calls)
|
|
mu.Unlock()
|
|
tb.renderScreen(1, tb.newScreen("main", "one", tb.homeRows()...))
|
|
|
|
mu.Lock()
|
|
after := append([]apiCall(nil), calls[before:]...)
|
|
mu.Unlock()
|
|
for _, c := range after {
|
|
if c.Method == "deleteMessage" || c.Method == "sendPhoto" || c.Method == "sendMessage" {
|
|
t.Fatalf("calls after the no-op edit = %v, want no replacement", methods(after))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScreenCaptionFitsTelegramLimitAndPages(t *testing.T) {
|
|
tb, _ := newScreenTgbot(t, false)
|
|
|
|
short := tb.newScreen("main", "hello", tb.homeRows()...)
|
|
if short.pageMax != 1 {
|
|
t.Fatalf("pageMax = %d, want 1 for a short body", short.pageMax)
|
|
}
|
|
tb.renderScreen(1, short)
|
|
if len(short.text) > botCaptionLimit {
|
|
t.Errorf("caption is %d chars, want at most %d", len(short.text), botCaptionLimit)
|
|
}
|
|
|
|
long := tb.newScreen("main", strings.Repeat("line of text\n", 300), tb.homeRows()...)
|
|
if long.pageMax < 2 {
|
|
t.Fatalf("pageMax = %d, want the long body paged", long.pageMax)
|
|
}
|
|
tb.renderScreen(1, long)
|
|
if len([]rune(long.text)) > botCaptionLimit {
|
|
t.Errorf("paged caption is %d runes, want at most %d", len([]rune(long.text)), botCaptionLimit)
|
|
}
|
|
// The pager must be reachable: a body nobody can page through is a dead end.
|
|
found := false
|
|
for _, row := range long.markup.InlineKeyboard {
|
|
for _, btn := range row {
|
|
if btn.CallbackData == cbPageNext {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("paged screen has no next-page button")
|
|
}
|
|
}
|
|
|
|
func TestHideButtonDeletesItsOwnMessage(t *testing.T) {
|
|
tb, calls := newScreenTgbot(t, false)
|
|
|
|
tb.hideMessage(&telego.CallbackQuery{
|
|
ID: "q1",
|
|
Message: &telego.Message{MessageID: 55, Chat: telego.Chat{ID: 1}},
|
|
})
|
|
|
|
got := calls()
|
|
for _, c := range got {
|
|
if c.Method != "deleteMessage" {
|
|
continue
|
|
}
|
|
if id, ok := c.Payload["message_id"].(float64); ok && int(id) == 55 {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("calls = %v, want deleteMessage of the tapped message 55", methods(got))
|
|
}
|
|
|
|
func TestHomeScreenCarriesTheMenu(t *testing.T) {
|
|
tb, _ := newScreenTgbot(t, false)
|
|
sc := tb.newScreen("main", "summary", tb.homeRows()...)
|
|
data := map[string]bool{}
|
|
for _, row := range sc.rows {
|
|
for _, btn := range row {
|
|
data[btn.CallbackData] = true
|
|
}
|
|
}
|
|
// The menu opens with categories only: each subject is one tap deeper, and
|
|
// the entry screen must not grow back into the wall of buttons.
|
|
for _, want := range []string{cbCatServer, cbCatClients, cbCatTraffic, cbCatMaintenance, cbHome} {
|
|
if !data[want] {
|
|
t.Errorf("menu is missing the %q button", want)
|
|
}
|
|
}
|
|
for _, old := range []string{cbServer, cbInbounds, cbClients, cbOnlines, cbDeplete, cbReport, cbBackup, cbBanLogs, cbAddClient} {
|
|
if data[old] {
|
|
t.Errorf("menu still carries %q directly; it belongs in a category", old)
|
|
}
|
|
}
|
|
|
|
// Every category the menu offers must be drawn, and carry its own entries.
|
|
for _, category := range []string{"server", "clients", "traffic", "maintenance"} {
|
|
rows := tb.categoryRows(category)
|
|
if len(rows) < 2 {
|
|
t.Errorf("category %q has %d rows, want its entries plus a way back", category, len(rows))
|
|
continue
|
|
}
|
|
flat := map[string]bool{}
|
|
for _, row := range rows {
|
|
for _, btn := range row {
|
|
flat[btn.CallbackData] = true
|
|
}
|
|
}
|
|
if !flat[cbHome] {
|
|
t.Errorf("category %q has no way back to the menu", category)
|
|
}
|
|
}
|
|
}
|
|
|
|
// hasHomeRow reports whether a screen carries the way back to the menu.
|
|
func hasHomeRow(sc *screen) bool {
|
|
for _, row := range sc.rows {
|
|
for _, btn := range row {
|
|
if btn.CallbackData == cbHome {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// A screen whose only buttons are its own actions traps the user once one runs, as the reset
|
|
// confirmation did; every screen must offer a way out.
|
|
func TestEveryScreenOffersAWayOut(t *testing.T) {
|
|
tb := &Tgbot{}
|
|
|
|
ownActionOnly := tb.newScreen("main", "body",
|
|
tu.InlineKeyboardRow(tb.btn("tgbot.buttons.confirmResetTraffic", "reset_all_traffics_c")))
|
|
if !hasHomeRow(ownActionOnly) {
|
|
t.Error("a screen with only its own action got no way back")
|
|
}
|
|
|
|
// A screen that already ends with Back must not grow a second copy.
|
|
withBack := tb.newScreen("main", "body", tb.backRow())
|
|
backs := 0
|
|
for _, row := range withBack.rows {
|
|
for _, btn := range row {
|
|
if btn.CallbackData == cbHome {
|
|
backs++
|
|
}
|
|
}
|
|
}
|
|
if backs != 1 {
|
|
t.Errorf("back rows = %d, want exactly 1", backs)
|
|
}
|
|
|
|
// And the screens the menu actually draws.
|
|
for name, sc := range map[string]*screen{
|
|
"reset confirmation": tb.resetAllConfirm(),
|
|
"home": tb.newScreen("main", "x", tb.homeRows()...),
|
|
"category": tb.newScreen("main", "x", tb.categoryRows("maintenance")...),
|
|
} {
|
|
if !hasHomeRow(sc) {
|
|
t.Errorf("%s has no way back to the menu", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The reset confirmation must not stay on screen after the reset runs: its
|
|
// Cancel was the way back and its only other button was the one just pressed.
|
|
func TestResetConfirmIsReplacedByTheMenu(t *testing.T) {
|
|
tb, calls := newScreenTgbot(t, false)
|
|
setTestAdmins(t, 1)
|
|
initReportDB(t)
|
|
seedReportClients(t, "nl-fast", []string{"a@x"})
|
|
|
|
tb.answerCallback(&telego.CallbackQuery{
|
|
ID: "q1",
|
|
From: telego.User{ID: 1},
|
|
Data: "reset_all_traffics_c",
|
|
Message: &telego.Message{Chat: telego.Chat{ID: 1}},
|
|
}, true)
|
|
|
|
if countMethod(calls(), "sendPhoto")+countMethod(calls(), "sendMessage") == 0 {
|
|
t.Fatal("the reset drew no message at all")
|
|
}
|
|
// The chat's tracked screen has to be the menu now, not the confirmation.
|
|
sc, ok := tb.screens().get(1)
|
|
if !ok {
|
|
t.Fatal("no screen is tracked after the reset")
|
|
}
|
|
if !hasHomeRow(sc) {
|
|
t.Error("the screen left behind after the reset has no way back")
|
|
}
|
|
if sc.kind != "main" {
|
|
t.Errorf("tracked screen kind = %q, want the menu", sc.kind)
|
|
}
|
|
}
|
|
|
|
// A client's /start must never render the admin menu, which advertised operations a client cannot run
|
|
// and listed every client's email.
|
|
func TestClientStartNeverShowsTheAdminMenu(t *testing.T) {
|
|
tb, _ := newScreenTgbot(t, false)
|
|
setTestAdmins(t, 1)
|
|
initReportDB(t)
|
|
seedReportClients(t, "nl-fast", []string{"mine@x"})
|
|
|
|
// A client: owns a record, is not an admin.
|
|
screenArtMu.Lock()
|
|
artFromAvatar, artProbedAt = false, time.Now()
|
|
screenArtMu.Unlock()
|
|
|
|
tb.screenHome(500, 777777)
|
|
|
|
sc, ok := tb.screens().get(500)
|
|
if !ok || sc.markup == nil {
|
|
// No record at all: the client is told its id instead. That is fine.
|
|
return
|
|
}
|
|
for _, row := range sc.rows {
|
|
for _, btn := range row {
|
|
switch btn.CallbackData {
|
|
case cbCatServer, cbCatClients, cbCatTraffic, cbCatMaintenance, cbBroadcast, cbAddClient:
|
|
t.Errorf("a client's menu carries the admin-only %q button", btn.CallbackData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The broadcast the /broadcast command already drove must be reachable as a
|
|
// button, and only for an admin.
|
|
func TestBroadcastButtonIsAdminOnly(t *testing.T) {
|
|
tb := &Tgbot{}
|
|
found := false
|
|
for _, category := range []string{"server", "clients", "traffic", "maintenance"} {
|
|
for _, row := range tb.categoryRows(category) {
|
|
for _, btn := range row {
|
|
if btn.CallbackData == cbBroadcast {
|
|
found = true
|
|
if category != "clients" {
|
|
t.Errorf("broadcast sits in %q, want the clients category", category)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("no category carries the broadcast button")
|
|
}
|
|
}
|
|
|
|
// A missing avatar is retried sooner than a present one: an operator checks right after uploading, and
|
|
// waiting for the next restart reads as the feature not working.
|
|
func TestScreenArtReprobeWindow(t *testing.T) {
|
|
srv, calls := recordingServer(t, false)
|
|
swapTestBot(t, srv.URL)
|
|
defer srv.Close()
|
|
resetScreenArt()
|
|
|
|
tb := &Tgbot{}
|
|
|
|
// A miss two minutes ago is past the retry window: a probe must fire.
|
|
screenArtMu.Lock()
|
|
artFromAvatar = false
|
|
artProbedAt = time.Now().Add(-2 * time.Minute)
|
|
screenArtMu.Unlock()
|
|
|
|
tb.ensureScreenArt()
|
|
for range 50 {
|
|
if countMethod(calls(), "getUserProfilePhotos") > 0 {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if countMethod(calls(), "getUserProfilePhotos") == 0 {
|
|
t.Error("a stale missing avatar was not re-probed")
|
|
}
|
|
|
|
// A known avatar is inside its long window: no probe.
|
|
screenArtMu.Lock()
|
|
artFromAvatar = true
|
|
artProbedAt = time.Now().Add(-2 * time.Minute)
|
|
screenArtMu.Unlock()
|
|
|
|
before := countMethod(calls(), "getUserProfilePhotos")
|
|
tb.ensureScreenArt()
|
|
time.Sleep(80 * time.Millisecond)
|
|
if countMethod(calls(), "getUserProfilePhotos") != before {
|
|
t.Error("a freshly read avatar was re-probed too eagerly")
|
|
}
|
|
}
|
|
|
|
func TestScreenKeepsArtFromTheBotAvatar(t *testing.T) {
|
|
tb, calls := newScreenTgbot(t, false)
|
|
tb.resolveScreenArt()
|
|
|
|
if _, ok := artFileID("main"); !ok {
|
|
t.Fatal("resolveScreenArt did not cache the bot avatar")
|
|
}
|
|
tb.renderScreen(1, tb.newScreen("main", "with art", tb.homeRows()...))
|
|
|
|
if countMethod(calls(), "getUserProfilePhotos") == 0 {
|
|
t.Error("the avatar probe never ran, so no picture can have been cached")
|
|
}
|
|
if countMethod(calls(), "sendPhoto") == 0 {
|
|
t.Errorf("calls = %v, want the screen sent as a photo", methods(calls()))
|
|
}
|
|
}
|
|
|
|
// sentText is what a Telegram call would display, plus its chat; a screen travels as a multipart photo,
|
|
// so reading only the JSON text would see nothing.
|
|
type sentText struct {
|
|
ChatID int64
|
|
Text string
|
|
}
|
|
|
|
// extractSentBody reads the displayable text and the target chat off a Bot API
|
|
// request, whichever shape the call used.
|
|
func extractSentBody(t *testing.T, r *http.Request) sentText {
|
|
t.Helper()
|
|
out := sentText{}
|
|
ctype := r.Header.Get("Content-Type")
|
|
if strings.HasPrefix(ctype, "multipart/form-data") {
|
|
if err := r.ParseMultipartForm(1 << 22); err != nil {
|
|
return out
|
|
}
|
|
out.Text = r.FormValue("caption")
|
|
if out.Text == "" {
|
|
out.Text = r.FormValue("text")
|
|
}
|
|
if media := r.FormValue("media"); media != "" {
|
|
var payload struct {
|
|
Caption string `json:"caption"`
|
|
}
|
|
if json.Unmarshal([]byte(media), &payload) == nil && payload.Caption != "" {
|
|
out.Text = payload.Caption
|
|
}
|
|
}
|
|
out.ChatID = parseInt64(r.FormValue("chat_id"))
|
|
return out
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
var payload struct {
|
|
ChatID json.RawMessage `json:"chat_id"`
|
|
Text string `json:"text"`
|
|
Caption string `json:"caption"`
|
|
Media json.RawMessage `json:"media"`
|
|
}
|
|
if json.Unmarshal(body, &payload) != nil {
|
|
return out
|
|
}
|
|
out.Text = payload.Text
|
|
if out.Text == "" {
|
|
out.Text = payload.Caption
|
|
}
|
|
if len(payload.Media) > 0 {
|
|
var media struct {
|
|
Caption string `json:"caption"`
|
|
}
|
|
if json.Unmarshal(payload.Media, &media) == nil && media.Caption != "" {
|
|
out.Text = media.Caption
|
|
}
|
|
}
|
|
var chat any
|
|
if json.Unmarshal(payload.ChatID, &chat) == nil {
|
|
if f, ok := chat.(float64); ok {
|
|
out.ChatID = int64(f)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseInt64(v string) int64 {
|
|
n, _ := strconv.ParseInt(v, 10, 64)
|
|
return n
|
|
}
|
|
|
|
func countMethod(calls []apiCall, method string) int {
|
|
n := 0
|
|
for _, c := range calls {
|
|
if c.Method == method {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// sheetBytesForTest reads the bytes back out of an uploadable file, so a test can
|
|
// decode the picture the bot would send.
|
|
func sheetBytesForTest(t *testing.T, file *telego.InputFile) []byte {
|
|
t.Helper()
|
|
// A byte-backed InputFile carries a NamedReader; reading it is what the
|
|
// upload would do.
|
|
if file.File == nil {
|
|
t.Fatal("the sheet carries no data")
|
|
}
|
|
data, err := io.ReadAll(file.File)
|
|
if err != nil {
|
|
t.Fatalf("read the sheet: %v", err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
// ord is rune to int, for the emoji range checks in the menu tests.
|
|
func ord(ch rune) int { return int(ch) }
|
|
|
|
// localizeWithRealBundle points the bot localizer at the shipped en-US file, so
|
|
// a test sees the text a user would, not the bare key.
|
|
func localizeWithRealBundle(t *testing.T) {
|
|
t.Helper()
|
|
path := filepath.Join(repoRootForTest(t), "internal/web/translation/en-US.json")
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
bundle := i18n.NewBundle(language.MustParse("en-US"))
|
|
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
|
if _, err := bundle.ParseMessageFileBytes(data, path); err != nil {
|
|
t.Fatalf("parse %s: %v", path, err)
|
|
}
|
|
orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US"))
|
|
t.Cleanup(func() { locale.SwapBotLocalizer(orig) })
|
|
}
|
|
|
|
// seeding a client bound to a Telegram user, which the client screens key on
|
|
func seedClientForTgUser(t *testing.T, tgUserID int64, email string) {
|
|
t.Helper()
|
|
// The panel keeps a client's Telegram binding in the settings blob and the clients.tg_id
|
|
// column; GetClientTrafficTgBot looks it up by the indexed column.
|
|
settings := fmt.Sprintf(`{"clients":[{"email":%q,"tgId":%d,"subId":"sub-%s"}]}`, email, tgUserID, email)
|
|
inbound := &model.Inbound{
|
|
UserId: 1, Tag: "tg-" + email, Remark: "tg-" + email, Port: 8443,
|
|
Protocol: model.VLESS, Enable: true, Settings: settings,
|
|
}
|
|
if err := database.GetDB().Create(inbound).Error; err != nil {
|
|
t.Fatalf("seed inbound for %s: %v", email, err)
|
|
}
|
|
if err := database.GetDB().Create(&xray.ClientTraffic{
|
|
InboundId: inbound.Id, Email: email, Enable: true, Up: 1, Down: 1,
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed traffic for %s: %v", email, err)
|
|
}
|
|
record := (&model.Client{Email: email, Enable: true, SubID: "sub-" + email, TgID: tgUserID}).ToRecord()
|
|
if err := database.GetDB().Create(record).Error; err != nil {
|
|
t.Fatalf("seed client %s: %v", email, err)
|
|
}
|
|
if err := database.GetDB().Create(&model.ClientInbound{ClientId: record.Id, InboundId: inbound.Id}).Error; err != nil {
|
|
t.Fatalf("seed client_inbounds for %s: %v", email, err)
|
|
}
|
|
}
|
|
|
|
// 1. The back button did nothing for a client: "home" was not one of the
|
|
// callbacks a non-admin was allowed to reach.
|
|
func TestClientBackButtonIsAllowed(t *testing.T) {
|
|
if !isClientSelfCallback("home") {
|
|
t.Fatal("a client's back button is still refused by the callback gate")
|
|
}
|
|
}
|
|
|
|
// A client with one subscription has nothing to choose: the links must open
|
|
// directly instead of asking the client to tap its own name.
|
|
func TestClientMenuWithOneSubscriptionOpensLinksDirectly(t *testing.T) {
|
|
tb, calls := newScreenTgbot(t, false)
|
|
initReportDB(t)
|
|
setTestAdmins(t)
|
|
seedClientForTgUser(t, 4242, "solo@x")
|
|
|
|
tb.screenClientHome(777, 4242)
|
|
|
|
if countMethod(calls(), "sendPhoto") == 0 {
|
|
t.Fatal("nothing was rendered")
|
|
}
|
|
sc, ok := tb.screens().get(777)
|
|
if !ok {
|
|
t.Fatal("no screen was tracked")
|
|
}
|
|
markup, _ := json.Marshal(sc.markup)
|
|
// The client's own top level: its subscription's real actions, right there,
|
|
// and none of the controls that used to do nothing.
|
|
for _, want := range []string{"client_sub_links", "client_qr_links"} {
|
|
if !strings.Contains(string(markup), want) {
|
|
t.Errorf("the client's menu lacks %s: %s", want, markup)
|
|
}
|
|
}
|
|
for _, gone := range []string{"client_commands", "client_traffic", "pleaseChoose", "\"home\""} {
|
|
if strings.Contains(string(markup), gone) {
|
|
t.Errorf("the dead or pointless %s control is still offered: %s", gone, markup)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Several subscriptions still need a choice, but one button per subscription
|
|
// rather than a "choose yourself" step.
|
|
func TestClientMenuWithSeveralSubscriptionsListsThem(t *testing.T) {
|
|
tb, _ := newScreenTgbot(t, false)
|
|
initReportDB(t)
|
|
setTestAdmins(t)
|
|
seedClientForTgUser(t, 4343, "a@x")
|
|
seedClientForTgUser(t, 4343, "b@x")
|
|
|
|
tb.screenClientHome(777, 4343)
|
|
|
|
sc, ok := tb.screens().get(777)
|
|
if !ok {
|
|
t.Fatal("no screen was tracked")
|
|
}
|
|
markup, _ := json.Marshal(sc.markup)
|
|
for _, email := range []string{"a@x", "b@x"} {
|
|
if !strings.Contains(string(markup), email) {
|
|
t.Errorf("the client's own subscription %q is not offered: %s", email, markup)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A QR screen shows the code as its own picture; it is not a file beside it.
|
|
func TestQRScreenCarriesTheCodeAsItsPicture(t *testing.T) {
|
|
tb, calls := newScreenTgbot(t, false)
|
|
initReportDB(t)
|
|
setTestAdmins(t)
|
|
seedClientForTgUser(t, 4444, "qr@x")
|
|
|
|
tb.screenClientQR(777, "qr@x")
|
|
|
|
if got := countMethod(calls(), "sendDocument"); got != 0 {
|
|
t.Errorf("sendDocument calls = %d, want the QR on the screen instead of as a file", got)
|
|
}
|
|
if got := countMethod(calls(), "sendPhoto"); got == 0 {
|
|
t.Fatal("the QR screen sent no picture")
|
|
}
|
|
}
|
|
|
|
// /start is a reset: leaving the broadcast flow must not strand its prompt.
|
|
func TestStartClearsTheBroadcastPrompt(t *testing.T) {
|
|
tb, calls := newScreenTgbot(t, false)
|
|
initReportDB(t)
|
|
setTestAdmins(t, 5555)
|
|
broadcastLocalizer(t)
|
|
t.Cleanup(func() { resetBroadcastState(t) })
|
|
setBroadcastRunning(t, true)
|
|
|
|
admin := chatUser{chatID: 5555, userID: 5555}
|
|
tb.startBroadcast(admin)
|
|
broadcastSetPrompt(admin, 4321)
|
|
|
|
before := countMethod(calls(), "deleteMessage")
|
|
tb.answerCommand(&telego.Message{
|
|
MessageID: 9, Chat: telego.Chat{ID: 5555}, From: &telego.User{ID: 5555},
|
|
Text: "/start",
|
|
}, 5555, true)
|
|
|
|
if got := countMethod(calls(), "deleteMessage"); got <= before {
|
|
t.Error("the broadcast prompt survived /start")
|
|
}
|
|
}
|
|
|
|
// Once /start has opened the menu the command itself is removed, and a second /start edits the live
|
|
// screen instead of leaving two of them.
|
|
func TestStartCommandIsRemovedAndEditsTheLiveScreen(t *testing.T) {
|
|
tb, calls := newScreenTgbot(t, false)
|
|
initReportDB(t)
|
|
setTestAdmins(t, 6666)
|
|
|
|
// The handler deletes the command after answering, exactly as the router
|
|
// wires it; calling both keeps the test on the real path.
|
|
start := func(messageID int) {
|
|
msg := &telego.Message{
|
|
MessageID: messageID,
|
|
Chat: telego.Chat{ID: 6666},
|
|
From: &telego.User{ID: 6666},
|
|
Text: "/start",
|
|
}
|
|
tb.answerCommand(msg, 6666, true)
|
|
tb.deleteIncoming(msg)
|
|
}
|
|
|
|
start(12)
|
|
if countMethod(calls(), "sendPhoto") != 1 {
|
|
t.Fatalf("sendPhoto calls = %d, want 1 for the first /start", countMethod(calls(), "sendPhoto"))
|
|
}
|
|
if countMethod(calls(), "deleteMessage") < 1 {
|
|
t.Error("the /start command message was left in the chat")
|
|
}
|
|
|
|
// A second /start must redraw the tracked screen, not send another one.
|
|
start(13)
|
|
if got := countMethod(calls(), "sendPhoto"); got != 1 {
|
|
t.Errorf("sendPhoto calls = %d, want 1: a second /start duplicated the menu", got)
|
|
}
|
|
if countMethod(calls(), "editMessageMedia") == 0 {
|
|
t.Error("the second /start did not edit the live screen")
|
|
}
|
|
}
|
|
|
|
// Callback-data tests pass a button whose key is missing (its label is ""), which is how /start broke;
|
|
// this one loads the real en-US file and insists the client's menu has visible labels.
|
|
func TestClientMenuButtonsHaveRealLabels(t *testing.T) {
|
|
localizeWithRealBundle(t)
|
|
tb, _ := newScreenTgbot(t, false)
|
|
initReportDB(t)
|
|
setTestAdmins(t)
|
|
seedClientForTgUser(t, 4545, "labelled@x")
|
|
|
|
tb.screenClientHome(777, 4545)
|
|
|
|
sc, ok := tb.screens().get(777)
|
|
if !ok {
|
|
t.Fatal("the client's /start produced no screen")
|
|
}
|
|
if len(sc.rows) == 0 {
|
|
t.Fatal("the client's screen has no buttons")
|
|
}
|
|
for _, row := range sc.rows {
|
|
for _, button := range row {
|
|
if strings.TrimSpace(button.Text) == "" {
|
|
t.Errorf("a button on the client's menu has an empty label (callback %q)", button.CallbackData)
|
|
}
|
|
if strings.HasPrefix(button.Text, "tgbot.") {
|
|
t.Errorf("button %q shows its raw key instead of text: the key is missing", button.CallbackData)
|
|
}
|
|
}
|
|
}
|
|
if strings.TrimSpace(sc.pages[0]) == "" {
|
|
t.Error("the client's screen body is empty")
|
|
}
|
|
}
|
|
|
|
// 1. A finished broadcast is a message like any other: its run controls go, and
|
|
// what stays is the hide button.
|
|
func TestFinishedBroadcastCardKeepsOnlyHide(t *testing.T) {
|
|
tb, calls := newScreenTgbot(t, false)
|
|
initReportDB(t)
|
|
broadcastLocalizer(t)
|
|
|
|
runner := &broadcastRunner{actor: chatUser{chatID: 100}, chatID: 100, messageID: 5}
|
|
if !tb.finalizeBroadcastCard(runner, "done") {
|
|
t.Fatal("the summary card could not be edited")
|
|
}
|
|
|
|
edits := 0
|
|
for _, call := range calls() {
|
|
if call.Method != "editMessageText" {
|
|
continue
|
|
}
|
|
edits++
|
|
markup, _ := json.Marshal(call.Payload["reply_markup"])
|
|
if !strings.Contains(string(markup), "\"hide\"") {
|
|
t.Errorf("the finished card offers no hide button: %s", markup)
|
|
}
|
|
if strings.Contains(string(markup), "\"broadcast_cancel\"") {
|
|
t.Errorf("the finished card still offers the run controls: %s", markup)
|
|
}
|
|
}
|
|
if edits != 1 {
|
|
t.Errorf("editMessageText calls = %d, want 1", edits)
|
|
}
|
|
}
|
|
|
|
// 3. "Client card" on the links screen only repeats the client's own usage, so a
|
|
// client must not be offered it; an admin still gets it.
|
|
func TestLinksScreenHidesTheClientCardFromItsOwner(t *testing.T) {
|
|
tb, _ := newScreenTgbot(t, false)
|
|
initReportDB(t)
|
|
setTestAdmins(t)
|
|
seedClientForTgUser(t, 4646, "mine@x")
|
|
|
|
tb.screenClientLinks(4646, "mine@x")
|
|
sc, ok := tb.screens().get(4646)
|
|
if !ok {
|
|
t.Fatal("no screen was tracked")
|
|
}
|
|
markup, _ := json.Marshal(sc.markup)
|
|
if strings.Contains(string(markup), "client_get_usage") {
|
|
t.Errorf("a client is still offered the admin's client card: %s", markup)
|
|
}
|
|
// The other links are what the client came for.
|
|
for _, want := range []string{"client_individual_links", "client_qr_links"} {
|
|
if !strings.Contains(string(markup), want) {
|
|
t.Errorf("the links screen lacks %s: %s", want, markup)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. The QR screen carries every code the subscription has, as one picture.
|
|
func TestQRScreenCarriesEveryCodeOnOnePicture(t *testing.T) {
|
|
tb, calls := newScreenTgbot(t, false)
|
|
initReportDB(t)
|
|
setTestAdmins(t)
|
|
seedClientForTgUser(t, 4747, "codes@x")
|
|
|
|
tb.screenClientQR(777, "codes@x")
|
|
|
|
if got := countMethod(calls(), "sendDocument"); got != 0 {
|
|
t.Errorf("sendDocument calls = %d, want the codes on the screen's picture", got)
|
|
}
|
|
if countMethod(calls(), "sendPhoto") == 0 {
|
|
t.Fatal("the QR screen sent no picture")
|
|
}
|
|
sc, ok := tb.screens().get(777)
|
|
if !ok {
|
|
t.Fatal("no screen was tracked")
|
|
}
|
|
if sc.qrPicture == nil {
|
|
t.Fatal("the QR screen carries no sheet of codes")
|
|
}
|
|
}
|
|
|
|
// The sheet itself, independent of any service: every code given is drawn, and
|
|
// the legend numbers exactly that many.
|
|
func TestQRSheetDrawsEveryCodeAndNumbersIt(t *testing.T) {
|
|
codes := []subscriptionQRCode{
|
|
{content: "https://panel.example/sub/abc", kind: "sub"},
|
|
{content: "https://panel.example/json/abc", kind: "json"},
|
|
{content: "vless://uuid@host:443?type=tcp#app1", kind: "link", app: 1},
|
|
{content: "vless://uuid@host:8443?type=ws#app2", kind: "link", app: 2},
|
|
}
|
|
|
|
picture := qrSheetPicture(codes)
|
|
if picture == nil {
|
|
t.Fatal("the sheet could not be rendered")
|
|
}
|
|
|
|
// Decode the encoded picture so a broken one is caught here: four codes in three columns make a 3x2
|
|
// sheet with padding between and around the tiles.
|
|
img, err := png.Decode(bytes.NewReader(sheetBytesForTest(t, picture)))
|
|
if err != nil {
|
|
t.Fatalf("the sheet is not a valid PNG: %v", err)
|
|
}
|
|
wantW := 3*qrTileSize + 4*qrSheetPad
|
|
wantH := 2*qrTileSize + 3*qrSheetPad
|
|
if img.Bounds().Dx() != wantW || img.Bounds().Dy() != wantH {
|
|
t.Errorf("sheet is %dx%d, want %dx%d", img.Bounds().Dx(), img.Bounds().Dy(), wantW, wantH)
|
|
}
|
|
}
|
|
|
|
// ru-Locale labels for the menu, so these tests are independent of the localizer.
|
|
func TestAdminCategoriesHaveEmojiOnEveryButton(t *testing.T) {
|
|
localizeWithRealBundle(t)
|
|
tb := &Tgbot{}
|
|
|
|
hasEmoji := func(s string) bool {
|
|
for _, ch := range s {
|
|
o := ord(ch)
|
|
if (o >= 0x1F000 && o <= 0x1FAFF) || (o >= 0x2600 && o <= 0x27BF) ||
|
|
(o >= 0x2B00 && o <= 0x2BFF) || o == 0xFE0F {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
for _, category := range []string{"server", "clients", "traffic", "maintenance"} {
|
|
rows := tb.categoryRows(category)
|
|
if len(rows) == 0 {
|
|
t.Fatalf("category %q has no buttons", category)
|
|
}
|
|
for _, row := range rows {
|
|
for _, button := range row {
|
|
if button.CallbackData == cbHome {
|
|
continue // the back row is a plain arrow by design
|
|
}
|
|
if !hasEmoji(button.Text) {
|
|
t.Errorf("category %q: button %q has no emoji: %q",
|
|
category, button.CallbackData, button.Text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A screen must not print "Refreshed on" twice: the card carries one and the
|
|
// caller used to append a second.
|
|
func TestClientScreensPrintRefreshedOnOnce(t *testing.T) {
|
|
tb, _ := newScreenTgbot(t, false)
|
|
initReportDB(t)
|
|
setTestAdmins(t)
|
|
seedClientForTgUser(t, 4848, "once@x")
|
|
|
|
// The admin's client card, which is where the duplicate was visible.
|
|
tb.searchClientScreen(777, "once@x")
|
|
sc, ok := tb.screens().get(777)
|
|
if !ok {
|
|
t.Fatal("no screen was tracked")
|
|
}
|
|
needle := tb.I18nBot("tgbot.messages.refreshedOn", "Time==x")
|
|
if needle == "" {
|
|
t.Skip("no localizer in this test environment")
|
|
}
|
|
// Compare on the key itself, which survives an empty localizer.
|
|
if got := strings.Count(sc.pages[0], "refreshedOn"); got > 1 {
|
|
t.Errorf("refreshedOn appears %d times on the client card", got)
|
|
}
|
|
if got := strings.Count(sc.body, "Refreshed"); got > 1 && needle != "" {
|
|
t.Errorf("the refreshed line is printed %d times", got)
|
|
}
|
|
}
|
|
|
|
// Inbounds belongs with the server, not under traffic: it says what the server
|
|
// listens on, and the traffic category is about quota.
|
|
func TestInboundsSitsInTheServerCategory(t *testing.T) {
|
|
tb := &Tgbot{}
|
|
in := func(category, want string) bool {
|
|
for _, row := range tb.categoryRows(category) {
|
|
for _, button := range row {
|
|
if button.CallbackData == want {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if !in("server", cbInbounds) {
|
|
t.Error("the Server category does not offer Inbounds")
|
|
}
|
|
if in("traffic", cbInbounds) {
|
|
t.Error("the Traffic category still offers Inbounds")
|
|
}
|
|
}
|
|
|
|
// A delivered broadcast copy carries the hide button when the recipient is one of
|
|
// the bot's admins: that recipient has no panel menu to clear it from.
|
|
func TestBroadcastCopyToAnotherAdminCarriesHide(t *testing.T) {
|
|
localizeWithRealBundle(t)
|
|
srv, calls := recordingServer(t, false)
|
|
swapTestBot(t, srv.URL)
|
|
t.Cleanup(srv.Close)
|
|
setTestAdmins(t, 111, 222)
|
|
|
|
if _, err := deliverBroadcastCopy(222, broadcastDraft{FromChatID: 111, MessageIDs: []int{5}}); err != nil {
|
|
t.Fatalf("deliverBroadcastCopy: %v", err)
|
|
}
|
|
|
|
got := false
|
|
for _, call := range calls() {
|
|
if call.Method != "editMessageReplyMarkup" {
|
|
continue
|
|
}
|
|
markup, _ := json.Marshal(call.Payload["reply_markup"])
|
|
if strings.Contains(string(markup), "\"hide\"") {
|
|
got = true
|
|
}
|
|
}
|
|
if !got {
|
|
t.Error("the copy delivered to an admin carries no hide button")
|
|
}
|
|
}
|
|
|
|
// 1. EVERY recipient gets the hide button, not just another admin: a client has
|
|
// no panel menu, so without it the copy cannot be dismissed at all.
|
|
func TestBroadcastCopyToAClientCarriesHide(t *testing.T) {
|
|
localizeWithRealBundle(t)
|
|
srv, calls := recordingServer(t, false)
|
|
swapTestBot(t, srv.URL)
|
|
t.Cleanup(srv.Close)
|
|
setTestAdmins(t, 111) // 222 is an ordinary client, not an admin
|
|
|
|
if _, err := deliverBroadcastCopy(222, broadcastDraft{FromChatID: 111, MessageIDs: []int{5}}); err != nil {
|
|
t.Fatalf("deliverBroadcastCopy: %v", err)
|
|
}
|
|
|
|
got := ""
|
|
for _, call := range calls() {
|
|
if call.Method != "editMessageReplyMarkup" {
|
|
continue
|
|
}
|
|
markup, _ := json.Marshal(call.Payload["reply_markup"])
|
|
got = string(markup)
|
|
}
|
|
if !strings.Contains(got, "\"hide\"") {
|
|
t.Errorf("the copy delivered to a client carries no hide button: %s", got)
|
|
}
|
|
// A blank label would be worse than English: a client's language need not be
|
|
// one the panel ships.
|
|
if !strings.Contains(got, "Hide") {
|
|
t.Errorf("the hide button has no visible label: %s", got)
|
|
}
|
|
}
|
|
|
|
// The hide button must reach a client through the callback gate, or it would be
|
|
// drawn and then silently ignored.
|
|
func TestClientCanReachTheHideCallback(t *testing.T) {
|
|
if !isClientSelfCallback("hide") {
|
|
t.Fatal("a client's hide button is refused by the callback gate")
|
|
}
|
|
}
|
|
|
|
// 2. The individual-links screen offered "Links", which did what the back row
|
|
// already does.
|
|
func TestIndividualLinksScreenHasNoDuplicateLinksButton(t *testing.T) {
|
|
// The ownership tests' harness seeds a client whose subscription resolves, which the links need; the
|
|
// keyboard is read directly because drawing the full screen needs a subscription the DB harness lacks.
|
|
rows := (&Tgbot{}).individualLinksRows()
|
|
if len(rows) != 1 || len(rows[0]) != 1 {
|
|
t.Fatalf("the individual-links screen has %d row(s), want exactly the back row", len(rows))
|
|
}
|
|
if got := rows[0][0].CallbackData; got != cbHome {
|
|
t.Errorf("the only control is %q, want the back row %q", got, cbHome)
|
|
}
|
|
}
|