diff --git a/internal/web/service/tgbot/tgbot_declined_callback_test.go b/internal/web/service/tgbot/tgbot_declined_callback_test.go new file mode 100644 index 000000000..523df13be --- /dev/null +++ b/internal/web/service/tgbot/tgbot_declined_callback_test.go @@ -0,0 +1,81 @@ +package tgbot + +import ( + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/web/global" + + "github.com/mymmrac/telego" +) + +func tapCallback(t *testing.T, tb *Tgbot, isAdmin bool, data string) { + t.Helper() + tb.answerCallback(&telego.CallbackQuery{ + ID: "q1", + From: telego.User{ID: 1}, + Data: data, + Message: &telego.Message{Chat: telego.Chat{ID: 1}}, + }, isAdmin) +} + +// decliningServer answers both methods a declined callback can reach, and +// leaves isRunning set so a notice would go out if the code decided to send one. +func decliningServer(t *testing.T) func(string) int { + t.Helper() + mock, calls := staleButtonServer(t, map[string]any{ + "answerCallbackQuery": map[string]any{"ok": true, "result": true}, + "sendMessage": map[string]any{"ok": true, "result": map[string]any{ + "message_id": 1, + "date": 0, + "chat": map[string]any{"id": 1, "type": "private"}, + }}, + }) + swapTestBot(t, mock.URL) + t.Cleanup(mock.Close) + + origRunning := isRunning + t.Cleanup(func() { isRunning = origRunning }) + isRunning = true + + return calls +} + +// Regression test: a payload the bot could not route was dropped without an +// answer, so Telegram kept the button spinning until the callback timed out. +func TestUnroutableCallbackIsAnswered(t *testing.T) { + for _, data := range []string{"no_such_callback", "get_backup_legacy", "add_client_legacy_step 5"} { + t.Run(data, func(t *testing.T) { + calls := decliningServer(t) + + tapCallback(t, &Tgbot{}, true, data) + + if n := calls("answerCallbackQuery"); n != 1 { + t.Errorf("answerCallbackQuery calls = %d, want 1: an unroutable tap must be answered", n) + } + if n := calls("sendMessage"); n != 0 { + t.Errorf("sendMessage calls = %d, want 0: an answer is not a posted message", n) + } + }) + } +} + +// Regression test: a button whose hash aged out was answered with nothing at all; +// the answer clears it and the chat notice survives a failed send. +func TestExpiredCallbackHashIsAnsweredAndReported(t *testing.T) { + calls := decliningServer(t) + + origHash := hashStorage + hashStorage = global.NewHashStorage(time.Minute) + t.Cleanup(func() { hashStorage = origHash }) + + // 32 hex characters — the shape decodeQuery looks up, and never stored. + tapCallback(t, &Tgbot{}, true, "0123456789abcdef0123456789abcdef") + + if n := calls("answerCallbackQuery"); n != 1 { + t.Errorf("answerCallbackQuery calls = %d, want 1: an expired button must be answered", n) + } + if n := calls("sendMessage"); n != 1 { + t.Errorf("sendMessage calls = %d, want 1: the notice is the durable half of the reply", n) + } +} diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go index a9bcd8d63..63407e499 100644 --- a/internal/web/service/tgbot/tgbot_router.go +++ b/internal/web/service/tgbot/tgbot_router.go @@ -316,6 +316,9 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool // get query from hash storage decodedQuery, err := t.decodeQuery(callbackQuery.Data) if err != nil { + // A button older than the 20-minute hash window is the common case + // here; the answer clears it, the message outlives a failed send. + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.noQuery")) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noQuery")) return } @@ -884,6 +887,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool return } t.editMessageCallbackTgBot(callbackQuery.Message.GetChat().ID, callbackQuery.Message.GetMessageID(), picker) + default: + // An unknown action with arguments is still a tap, and an + // unanswered tap spins until Telegram times it out. + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) } return } else { @@ -1327,6 +1334,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool case "client_qr_links": t.sendClientQRLinks(chatId, email) } + + // Nothing matched: an unknown button still has to be answered, or it + // keeps spinning until Telegram times the callback out. + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) } }