diff --git a/internal/web/service/tgbot/tgbot_send.go b/internal/web/service/tgbot/tgbot_send.go index b86a13b7b..fc3e42683 100644 --- a/internal/web/service/tgbot/tgbot_send.go +++ b/internal/web/service/tgbot/tgbot_send.go @@ -210,6 +210,10 @@ func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyb ReplyMarkup: inlineKeyboard, } if _, err := bot.EditMessageReplyMarkup(context.Background(), ¶ms); err != nil { + if isTelegramNotModifiedError(err) { + logger.Debug("Telegram reply markup unchanged, skipping edit") + return + } logger.Warning(err) } } @@ -226,10 +230,25 @@ func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlin params.ReplyMarkup = inlineKeyboard[0] } if _, err := bot.EditMessageText(context.Background(), ¶ms); err != nil { + if isTelegramNotModifiedError(err) { + logger.Debug("Telegram message text unchanged, skipping edit") + return + } logger.Warning(err) } } +// Telegram answers a no-op edit with a 400 whose description carries this text; +// a refresh tap that changed nothing is not an operator-visible failure. +func isTelegramNotModifiedError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "not modified") || + strings.Contains(errStr, "No fields to modify") +} + // SendMsgToTgbotDeleteAfter sends a message and deletes it after a specified delay. func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSeconds int, replyMarkup ...telego.ReplyMarkup) { // Determine if replyMarkup was passed; otherwise, set it to nil diff --git a/internal/web/service/tgbot/tgbot_send_test.go b/internal/web/service/tgbot/tgbot_send_test.go index 054162774..a47211b71 100644 --- a/internal/web/service/tgbot/tgbot_send_test.go +++ b/internal/web/service/tgbot/tgbot_send_test.go @@ -1,10 +1,110 @@ package tgbot import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" "strings" "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" ) +func TestIsTelegramNotModifiedError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"nil error", nil, false}, + {"not modified", errors.New("Bad Request: message is not modified"), true}, + {"No fields to modify", errors.New("Bad Request: No fields to modify"), true}, + {"unrelated error", errors.New("Bad Request: message to edit not found"), false}, + {"network error", errors.New("connection reset"), false}, + {"empty string", errors.New(""), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isTelegramNotModifiedError(tt.err) + if got != tt.want { + t.Errorf("isTelegramNotModifiedError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +func TestEditMessageTgBotSkipsNotModified(t *testing.T) { + // Mock Telegram API that always returns "message is not modified". + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "ok": false, + "error_code": 400, + "description": "Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message.", + }) + })) + defer mock.Close() + + // Point the package-level bot at the mock. + origBot := bot + t.Cleanup(func() { bot = origBot }) + var err error + bot, err = telego.NewBot("test-token", telego.WithAPIServer(mock.URL)) + if err != nil { + t.Fatalf("NewBot: %v", err) + } + + // Snapshot warning count before the edit call. + before := logger.GetLogs(100, "warning") + + tb := &Tgbot{} + tb.editMessageTgBot(123, 456, "hello") + + after := logger.GetLogs(100, "warning") + if len(after) > len(before) { + t.Errorf("editMessageTgBot logged %d new warnings, want 0; new entries: %v", + len(after)-len(before), after[len(before):]) + } +} + +func TestEditMessageCallbackTgBotSkipsNotModified(t *testing.T) { + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "ok": false, + "error_code": 400, + "description": "Bad Request: message is not modified", + }) + })) + defer mock.Close() + + origBot := bot + t.Cleanup(func() { bot = origBot }) + var err error + bot, err = telego.NewBot("test-token", telego.WithAPIServer(mock.URL)) + if err != nil { + t.Fatalf("NewBot: %v", err) + } + + before := logger.GetLogs(100, "warning") + + tb := &Tgbot{} + kb := tu.InlineKeyboard(tu.InlineKeyboardRow( + tu.InlineKeyboardButton("btn").WithCallbackData("test"), + )) + tb.editMessageCallbackTgBot(123, 456, kb) + + after := logger.GetLogs(100, "warning") + if len(after) > len(before) { + t.Errorf("editMessageCallbackTgBot logged %d new warnings, want 0; new entries: %v", + len(after)-len(before), after[len(before):]) + } +} + func TestPageMessageSplitsLinkListWithoutBlankLines(t *testing.T) { var message strings.Builder message.WriteString("Individual links:\r\n")