Browse Source

fix(tgbot): suppress 'message not modified' warnings in Telegram edit calls (#6340)

When users click Refresh buttons in the Telegram bot (usage_refresh,
client_refresh, ips_refresh, onlines_refresh), editMessageText and
editMessageReplyMarkup are always called even when the content has not
changed. Telegram returns a 400 "message is not modified" error which
was logged as Warning, cluttering the logs on every refresh click.

Add isTelegramNotModifiedError helper that detects this specific
Telegram API error and logs it at Debug level instead of Warning.
BlindMaster24 9 giờ trước cách đây
mục cha
commit
8e13f8b172

+ 19 - 0
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(), &params); 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(), &params); 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

+ 100 - 0
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, "<b>hello</b>")
+
+	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")