mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 08:35:00 +03:00
fix(tgbot): keep backup files in the chat and the menu below them (#7092)
Every bot notice carries a Hide button, which deletes the message it sits on. On a backup that message is the file itself: one tap, with no confirmation, could remove the admin's only copy of the database. Backup files (the .db and config.json, from the backup button, the scheduled job and the panel's backup-to-Telegram endpoint) are now sent without a keyboard; other notices, including the ban log file, keep Hide. The backup button also left the menu above the files it sent, out of view at the bottom of the chat. It now sends the Maintenance menu again below them and removes the old one. A scheduled backup leaves the menu where it is.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
)
|
||||
|
||||
const testXrayConfig = `{"log":{}}`
|
||||
|
||||
// backupFixture gives GetDb and the config read real files in a throwaway folder.
|
||||
func backupFixture(t *testing.T) (*Tgbot, func() []apiCall) {
|
||||
t.Helper()
|
||||
_, calls := newScreenTgbot(t, false)
|
||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||
binFolder := t.TempDir()
|
||||
t.Setenv("XUI_BIN_FOLDER", binFolder)
|
||||
if err := os.WriteFile(filepath.Join(binFolder, "config.json"), []byte(testXrayConfig), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tb := initReportDB(t)
|
||||
setTestAdmins(t, 1)
|
||||
tb.resolveScreenArt()
|
||||
return tb, calls
|
||||
}
|
||||
|
||||
func documentsOf(calls []apiCall) []apiCall {
|
||||
var documents []apiCall
|
||||
for _, call := range calls {
|
||||
if call.Method == "sendDocument" {
|
||||
documents = append(documents, call)
|
||||
}
|
||||
}
|
||||
return documents
|
||||
}
|
||||
|
||||
// A Hide button on a backup deleted the admin's only copy of the database with
|
||||
// one tap, so neither delivery path may offer a way to delete the files.
|
||||
func TestBackupFilesCarryNoHideButton(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
deliver func(tb *Tgbot)
|
||||
}{
|
||||
{"backup button", func(tb *Tgbot) {
|
||||
tb.answerCallback(&telego.CallbackQuery{
|
||||
ID: "q1", From: telego.User{ID: 1}, Data: cbBackup,
|
||||
Message: &telego.Message{MessageID: 50, Chat: telego.Chat{ID: 1, Type: "private"}},
|
||||
}, true)
|
||||
}},
|
||||
{"scheduled backup", func(tb *Tgbot) { tb.SendBackupToAdmins() }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tb, calls := backupFixture(t)
|
||||
tc.deliver(tb)
|
||||
|
||||
documents := documentsOf(calls())
|
||||
if len(documents) != 2 {
|
||||
t.Fatalf("documents = %d, want the database and config.json (calls: %v)", len(documents), methods(calls()))
|
||||
}
|
||||
dbName, _ := documents[0].Payload["filename"].(string)
|
||||
if !strings.HasSuffix(dbName, ".db") || documents[1].Payload["filename"] != "config.json" {
|
||||
t.Errorf("files = %q, %v; want a .db then config.json", dbName, documents[1].Payload["filename"])
|
||||
}
|
||||
for _, document := range documents {
|
||||
if markup, ok := document.Payload["reply_markup"]; ok {
|
||||
t.Errorf("%v carries a keyboard that can delete it: %v", document.Payload["filename"], markup)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The files used to land under the menu and bury it, so the bot looked dead to
|
||||
// an admin who stayed at the bottom of the chat.
|
||||
func TestBackupButtonMovesTheMenuBelowTheFiles(t *testing.T) {
|
||||
tb, calls := backupFixture(t)
|
||||
const chatID int64 = 1
|
||||
tb.screenCategory(chatID, "maintenance")
|
||||
menu, ok := tb.screens().get(chatID)
|
||||
if !ok {
|
||||
t.Fatal("menu not tracked")
|
||||
}
|
||||
before := len(calls())
|
||||
|
||||
tb.answerCallback(&telego.CallbackQuery{
|
||||
ID: "q1", From: telego.User{ID: 1}, Data: cbBackup,
|
||||
Message: &telego.Message{MessageID: menu.msgID, Chat: telego.Chat{ID: chatID, Type: "private"}},
|
||||
}, true)
|
||||
|
||||
got := calls()[before:]
|
||||
want := []string{"answerCallbackQuery", "sendDocument", "sendDocument", "sendPhoto", "deleteMessage"}
|
||||
if !reflect.DeepEqual(methods(got), want) {
|
||||
t.Fatalf("calls = %v, want %v", methods(got), want)
|
||||
}
|
||||
if deleted := got[4].Payload["message_id"]; deleted != float64(menu.msgID) {
|
||||
t.Errorf("deleted message %v, want the old menu %d", deleted, menu.msgID)
|
||||
}
|
||||
current, ok := tb.screens().get(chatID)
|
||||
if !ok || current.msgID == menu.msgID || !hasButton(current.rows, cbBackup) {
|
||||
t.Errorf("tracked screen = %+v, want a new maintenance menu", current)
|
||||
}
|
||||
}
|
||||
|
||||
// A scheduled backup arrives unprompted: it must not move a menu the admin may
|
||||
// be using at that moment.
|
||||
func TestScheduledBackupLeavesTheMenuInPlace(t *testing.T) {
|
||||
tb, calls := backupFixture(t)
|
||||
tb.screenCategory(1, "maintenance")
|
||||
menu, _ := tb.screens().get(1)
|
||||
before := len(calls())
|
||||
|
||||
tb.SendBackupToAdmins()
|
||||
|
||||
if got := methods(calls()[before:]); !reflect.DeepEqual(got, []string{"sendDocument", "sendDocument"}) {
|
||||
t.Errorf("calls = %v, want only the two files", got)
|
||||
}
|
||||
if current, _ := tb.screens().get(1); current.msgID != menu.msgID {
|
||||
t.Errorf("tracked menu moved from %d to %d", menu.msgID, current.msgID)
|
||||
}
|
||||
}
|
||||
|
||||
func hasButton(rows [][]telego.InlineKeyboardButton, data string) bool {
|
||||
for _, row := range rows {
|
||||
for _, button := range row {
|
||||
if button.CallbackData == data {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -129,9 +129,9 @@ func (t *Tgbot) dropNoticeFor(chatID int64, msgID int) {
|
||||
}
|
||||
}
|
||||
|
||||
// sendDocumentWithCaption uploads a file with its summary as caption: a document
|
||||
// cannot hold a screen, so one message does the work of two.
|
||||
func (t *Tgbot) sendDocumentWithCaption(chatID int64, data []byte, name, caption string) bool {
|
||||
// sendBackupFile uploads a file with its summary as caption. It has no hide button:
|
||||
// that button deletes the message, and this may be the admin's only copy.
|
||||
func (t *Tgbot) sendBackupFile(chatID int64, data []byte, name, caption string) bool {
|
||||
if liveBot() == nil || data == nil {
|
||||
return false
|
||||
}
|
||||
@@ -139,10 +139,9 @@ func (t *Tgbot) sendDocumentWithCaption(chatID int64, data []byte, name, caption
|
||||
defer cancel()
|
||||
_, err := liveBot().SendDocument(ctx, &telego.SendDocumentParams{
|
||||
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID),
|
||||
Document: tu.FileFromBytes(data, name),
|
||||
Caption: caption,
|
||||
ParseMode: "HTML",
|
||||
ReplyMarkup: t.hideButton(),
|
||||
Document: tu.FileFromBytes(data, name),
|
||||
Caption: caption,
|
||||
ParseMode: "HTML",
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to upload a file with a caption:", err)
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestBackupArrivesAsOneMessageWithACaption(t *testing.T) {
|
||||
notices.reset()
|
||||
|
||||
tb := initReportDB(t)
|
||||
tb.sendDocumentWithCaption(1, []byte("db-bytes"), "x-ui.db", "host\nbackup time")
|
||||
tb.sendBackupFile(1, []byte("db-bytes"), "x-ui.db", "host\nbackup time")
|
||||
|
||||
got := calls()
|
||||
sends := 0
|
||||
|
||||
@@ -89,11 +89,11 @@ func (t *Tgbot) categoryRows(category string) [][]telego.InlineKeyboardButton {
|
||||
|
||||
// screenCategory draws one menu subject. The hint line names the subject, so a
|
||||
// user who arrived through a notification can see where they are.
|
||||
func (t *Tgbot) screenCategory(chatID int64, category string) {
|
||||
func (t *Tgbot) screenCategory(chatID int64, category string, delivery ...screenDelivery) {
|
||||
body := t.I18nBot("tgbot.messages.category_" + category)
|
||||
rows := [][]telego.InlineKeyboardButton{}
|
||||
rows = append(rows, t.categoryRows(category)...)
|
||||
t.renderScreen(chatID, t.newScreen("main", body, rows...))
|
||||
t.renderScreen(chatID, t.newScreen("main", body, rows...), delivery...)
|
||||
}
|
||||
|
||||
// btn is where a localized label becomes a callback button; btn2 is btn with a
|
||||
|
||||
@@ -266,11 +266,11 @@ func (t *Tgbot) sendBackupScreen(chatID int64) {
|
||||
// carries the backup and what it is for.
|
||||
body := summary + "\n\n" + t.depleteReport()
|
||||
caption := trimCaption(body, botCaptionLimit)
|
||||
if t.sendDocumentWithCaption(chatID, dbData, t.serverService.BackupFilename(""), caption) {
|
||||
if t.sendBackupFile(chatID, dbData, t.serverService.BackupFilename(""), caption) {
|
||||
// The generated config travels with the DB on both backup paths: an
|
||||
// operator restoring from the file would otherwise lose it silently.
|
||||
if config, err := os.ReadFile(xray.GetConfigPath()); err == nil {
|
||||
t.sendDocumentWithCaption(chatID, config, "config.json", "")
|
||||
t.sendBackupFile(chatID, config, "config.json", "")
|
||||
}
|
||||
if rest := strings.TrimSpace(strings.TrimPrefix(body, caption)); rest != "" {
|
||||
// Rare: the summary outgrew a caption. The file already landed.
|
||||
|
||||
@@ -60,7 +60,8 @@ func recordingServer(t *testing.T, failEdit bool, overrides ...map[string]any) (
|
||||
if strings.HasPrefix(string(body), "{") {
|
||||
// JSON payload: keep it as decoded.
|
||||
} else {
|
||||
payload = map[string]any{}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
payload = multipartPayload(r)
|
||||
}
|
||||
mu.Lock()
|
||||
calls = append(calls, apiCall{Method: method, Payload: payload})
|
||||
@@ -139,6 +140,22 @@ func recordingServer(t *testing.T, failEdit bool, overrides ...map[string]any) (
|
||||
}
|
||||
}
|
||||
|
||||
// multipartPayload exposes an upload's form fields as a payload; the uploaded
|
||||
// document's file name is under "filename".
|
||||
func multipartPayload(r *http.Request) map[string]any {
|
||||
payload := map[string]any{}
|
||||
if r.ParseMultipartForm(1<<22) != nil {
|
||||
return payload
|
||||
}
|
||||
for name, values := range r.MultipartForm.Value {
|
||||
payload[name] = values[0]
|
||||
}
|
||||
if files := r.MultipartForm.File["document"]; len(files) > 0 {
|
||||
payload["filename"] = files[0].Filename
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -123,10 +123,10 @@ func (t *Tgbot) SendBackupToAdmins() {
|
||||
if dbData == nil {
|
||||
t.sendNotice(adminId, t.I18nBot("tgbot.messages.backupFailed"))
|
||||
} else {
|
||||
t.sendDocumentWithCaption(adminId, dbData, dbFilename, t.backupSummary())
|
||||
t.sendBackupFile(adminId, dbData, dbFilename, t.backupSummary())
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if configErr == nil {
|
||||
t.sendDocumentWithCaption(adminId, config, "config.json", "")
|
||||
t.sendBackupFile(adminId, config, "config.json", "")
|
||||
}
|
||||
}
|
||||
// Add delay between sends to avoid Telegram rate limits
|
||||
|
||||
@@ -1000,6 +1000,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||
case "bkp", "get_backup":
|
||||
t.answerSilent(callbackQuery.ID)
|
||||
t.sendBackupScreen(chatId)
|
||||
// The files landed under the menu; bring it back below them so it stays in view.
|
||||
t.screenCategory(chatId, "maintenance", screenSendNew)
|
||||
case "ban", "get_banlogs":
|
||||
t.answerSilent(callbackQuery.ID)
|
||||
t.screenBanLogs(chatId)
|
||||
|
||||
Reference in New Issue
Block a user