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:
PluS98
2026-11-13 02:42:16 +03:00
committed by GitHub
parent 243b5c9da7
commit 3a28d130a7
8 changed files with 168 additions and 15 deletions
@@ -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
}
+6 -7
View File
@@ -129,9 +129,9 @@ func (t *Tgbot) dropNoticeFor(chatID int64, msgID int) {
} }
} }
// sendDocumentWithCaption uploads a file with its summary as caption: a document // sendBackupFile uploads a file with its summary as caption. It has no hide button:
// cannot hold a screen, so one message does the work of two. // that button deletes the message, and this may be the admin's only copy.
func (t *Tgbot) sendDocumentWithCaption(chatID int64, data []byte, name, caption string) bool { func (t *Tgbot) sendBackupFile(chatID int64, data []byte, name, caption string) bool {
if liveBot() == nil || data == nil { if liveBot() == nil || data == nil {
return false return false
} }
@@ -139,10 +139,9 @@ func (t *Tgbot) sendDocumentWithCaption(chatID int64, data []byte, name, caption
defer cancel() defer cancel()
_, err := liveBot().SendDocument(ctx, &telego.SendDocumentParams{ _, err := liveBot().SendDocument(ctx, &telego.SendDocumentParams{
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID),
Document: tu.FileFromBytes(data, name), Document: tu.FileFromBytes(data, name),
Caption: caption, Caption: caption,
ParseMode: "HTML", ParseMode: "HTML",
ReplyMarkup: t.hideButton(),
}) })
if err != nil { if err != nil {
logger.Warning("Failed to upload a file with a caption:", err) logger.Warning("Failed to upload a file with a caption:", err)
+1 -1
View File
@@ -96,7 +96,7 @@ func TestBackupArrivesAsOneMessageWithACaption(t *testing.T) {
notices.reset() notices.reset()
tb := initReportDB(t) 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() got := calls()
sends := 0 sends := 0
+2 -2
View File
@@ -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 // screenCategory draws one menu subject. The hint line names the subject, so a
// user who arrived through a notification can see where they are. // 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) body := t.I18nBot("tgbot.messages.category_" + category)
rows := [][]telego.InlineKeyboardButton{} rows := [][]telego.InlineKeyboardButton{}
rows = append(rows, t.categoryRows(category)...) 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 // btn is where a localized label becomes a callback button; btn2 is btn with a
+2 -2
View File
@@ -266,11 +266,11 @@ func (t *Tgbot) sendBackupScreen(chatID int64) {
// carries the backup and what it is for. // carries the backup and what it is for.
body := summary + "\n\n" + t.depleteReport() body := summary + "\n\n" + t.depleteReport()
caption := trimCaption(body, botCaptionLimit) 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 // The generated config travels with the DB on both backup paths: an
// operator restoring from the file would otherwise lose it silently. // operator restoring from the file would otherwise lose it silently.
if config, err := os.ReadFile(xray.GetConfigPath()); err == nil { 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 != "" { if rest := strings.TrimSpace(strings.TrimPrefix(body, caption)); rest != "" {
// Rare: the summary outgrew a caption. The file already landed. // Rare: the summary outgrew a caption. The file already landed.
+18 -1
View File
@@ -60,7 +60,8 @@ func recordingServer(t *testing.T, failEdit bool, overrides ...map[string]any) (
if strings.HasPrefix(string(body), "{") { if strings.HasPrefix(string(body), "{") {
// JSON payload: keep it as decoded. // JSON payload: keep it as decoded.
} else { } else {
payload = map[string]any{} r.Body = io.NopCloser(bytes.NewReader(body))
payload = multipartPayload(r)
} }
mu.Lock() mu.Lock()
calls = append(calls, apiCall{Method: method, Payload: payload}) 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 // newScreenTgbot points the package bot at a recording server and clears the
// process-wide screen state the previous test may have left behind. // process-wide screen state the previous test may have left behind.
func newScreenTgbot(t *testing.T, failEdit bool) (*Tgbot, func() []apiCall) { func newScreenTgbot(t *testing.T, failEdit bool) (*Tgbot, func() []apiCall) {
+2 -2
View File
@@ -123,10 +123,10 @@ func (t *Tgbot) SendBackupToAdmins() {
if dbData == nil { if dbData == nil {
t.sendNotice(adminId, t.I18nBot("tgbot.messages.backupFailed")) t.sendNotice(adminId, t.I18nBot("tgbot.messages.backupFailed"))
} else { } else {
t.sendDocumentWithCaption(adminId, dbData, dbFilename, t.backupSummary()) t.sendBackupFile(adminId, dbData, dbFilename, t.backupSummary())
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
if configErr == nil { if configErr == nil {
t.sendDocumentWithCaption(adminId, config, "config.json", "") t.sendBackupFile(adminId, config, "config.json", "")
} }
} }
// Add delay between sends to avoid Telegram rate limits // 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": case "bkp", "get_backup":
t.answerSilent(callbackQuery.ID) t.answerSilent(callbackQuery.ID)
t.sendBackupScreen(chatId) 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": case "ban", "get_banlogs":
t.answerSilent(callbackQuery.ID) t.answerSilent(callbackQuery.ID)
t.screenBanLogs(chatId) t.screenBanLogs(chatId)