Files
3x-ui/internal/web/service/tgbot/tgbot_send.go
T
MHSanaei 588a524fa5 fix(tgbot): stop auto-deleted messages from resetting wizard state
SendMsgToTgbotDeleteAfter spawns a goroutine that, after the display delay,
deleted the transient message and then unconditionally cleared the chat's
conversation state. Every caller that ends a wizard step already clears the
state synchronously, so that call was redundant — and harmful: if within
the delay the user advanced to the next step (a callback sets a fresh
awaiting_* state), the late goroutine wiped it, and the user's next message
fell through unrecognized, silently dropping their input.

Move the delayed deletion into deleteMessageAfterDelay, which only removes
the message and no longer touches the conversation state. Guard
deleteMessageTgBot against a nil bot so the deletion path is unit-testable.
2026-07-15 04:54:41 +02:00

276 lines
9.4 KiB
Go

package tgbot
import (
"context"
"fmt"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// sendResponse sends the response message based on the onlyMessage flag.
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
if onlyMessage {
t.SendMsgToTgbot(chatId, msg)
} else {
t.SendAnswer(chatId, msg, isAdmin)
}
}
// SendAnswer sends a response message with an inline keyboard to the specified chat.
func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
numericKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.SortedTrafficUsageReport")).WithCallbackData(t.encodeQuery("get_sorted_traffic_usage_report")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.serverUsage")).WithCallbackData(t.encodeQuery("get_usage")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ResetAllTraffics")).WithCallbackData(t.encodeQuery("reset_all_traffics")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.dbBackup")).WithCallbackData(t.encodeQuery("get_backup")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getBanLogs")).WithCallbackData(t.encodeQuery("get_banlogs")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getInbounds")).WithCallbackData(t.encodeQuery("inbounds")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.depleteSoon")).WithCallbackData(t.encodeQuery("deplete_soon")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("commands")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.onlines")).WithCallbackData(t.encodeQuery("onlines")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("admin_client_sub_links")),
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("admin_client_individual_links")),
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("admin_client_qr_links")),
),
// TODOOOOOOOOOOOOOO: Add restart button here.
)
numericKeyboardClient := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")),
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")),
),
)
var ReplyMarkup telego.ReplyMarkup
if isAdmin {
ReplyMarkup = numericKeyboard
} else {
ReplyMarkup = numericKeyboardClient
}
t.SendMsgToTgbot(chatId, msg, ReplyMarkup)
}
// SendMsgToTgbot sends a message to the Telegram bot with optional reply markup.
func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) {
if !isRunning {
return
}
if msg == "" {
logger.Info("[tgbot] message is empty!")
return
}
var allMessages []string
limit := 2000
// paging message if it is big
if len(msg) > limit {
messages := strings.Split(msg, "\r\n\r\n")
lastIndex := -1
for _, message := range messages {
if (len(allMessages) == 0) || (len(allMessages[lastIndex])+len(message) > limit) {
allMessages = append(allMessages, message)
lastIndex++
} else {
allMessages[lastIndex] += "\r\n\r\n" + message
}
}
if strings.TrimSpace(allMessages[len(allMessages)-1]) == "" {
allMessages = allMessages[:len(allMessages)-1]
}
} else {
allMessages = append(allMessages, msg)
}
for n, message := range allMessages {
params := telego.SendMessageParams{
ChatID: tu.ID(chatId),
Text: message,
ParseMode: "HTML",
}
// only add replyMarkup to last message
if len(replyMarkup) > 0 && n == (len(allMessages)-1) {
params.ReplyMarkup = replyMarkup[0]
}
// Retry logic with exponential backoff for connection errors
maxRetries := 3
for attempt := range maxRetries {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
_, err := bot.SendMessage(ctx, &params)
cancel()
if err == nil {
break // Success
}
// Check if error is a connection error
errStr := err.Error()
isConnectionError := strings.Contains(errStr, "connection") ||
strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "closed")
if isConnectionError && attempt < maxRetries-1 {
// Exponential backoff: 1s, 2s, 4s
backoff := time.Duration(1<<uint(attempt)) * time.Second
logger.Warningf("Connection error sending telegram message (attempt %d/%d), retrying in %v: %v",
attempt+1, maxRetries, backoff, err)
time.Sleep(backoff)
} else {
logger.Warning("Error sending telegram message:", err)
break
}
}
// Reduced delay to improve performance (only needed for rate limiting)
if n < len(allMessages)-1 { // Only delay between messages, not after the last one
time.Sleep(100 * time.Millisecond)
}
}
}
// SendMsgToTgbotAdmins sends a message to all admin Telegram chats.
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
if len(replyMarkup) > 0 {
for _, adminId := range adminIds {
t.SendMsgToTgbot(adminId, msg, replyMarkup[0])
}
} else {
for _, adminId := range adminIds {
t.SendMsgToTgbot(adminId, msg)
}
}
}
// sendCallbackAnswerTgBot answers a callback query with a message.
func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) {
params := telego.AnswerCallbackQueryParams{
CallbackQueryID: id,
Text: message,
}
if err := bot.AnswerCallbackQuery(context.Background(), &params); err != nil {
logger.Warning(err)
}
}
// editMessageCallbackTgBot edits the reply markup of a message.
func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyboard *telego.InlineKeyboardMarkup) {
params := telego.EditMessageReplyMarkupParams{
ChatID: tu.ID(chatId),
MessageID: messageID,
ReplyMarkup: inlineKeyboard,
}
if _, err := bot.EditMessageReplyMarkup(context.Background(), &params); err != nil {
logger.Warning(err)
}
}
// editMessageTgBot edits the text and reply markup of a message.
func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlineKeyboard ...*telego.InlineKeyboardMarkup) {
params := telego.EditMessageTextParams{
ChatID: tu.ID(chatId),
MessageID: messageID,
Text: text,
ParseMode: "HTML",
}
if len(inlineKeyboard) > 0 {
params.ReplyMarkup = inlineKeyboard[0]
}
if _, err := bot.EditMessageText(context.Background(), &params); err != nil {
logger.Warning(err)
}
}
// 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
var replyMarkupParam telego.ReplyMarkup
if len(replyMarkup) > 0 {
replyMarkupParam = replyMarkup[0] // Use the first element
}
// Send the message
sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{
ChatID: tu.ID(chatId),
Text: msg,
ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value
})
if err != nil {
logger.Warning("Failed to send message:", err)
return
}
// Delete the sent message after the specified number of seconds.
go t.deleteMessageAfterDelay(chatId, sentMsg.MessageID, delayInSeconds)
}
// deleteMessageAfterDelay waits delayInSeconds and then removes the message. It
// deliberately does not touch the conversation state: every caller that ends a
// wizard step already clears the state synchronously, and clearing it here — up
// to several seconds later — would wipe a state the user set for the next step
// in the meantime, silently dropping their following input.
func (t *Tgbot) deleteMessageAfterDelay(chatId int64, messageID, delayInSeconds int) {
time.Sleep(time.Duration(delayInSeconds) * time.Second)
t.deleteMessageTgBot(chatId, messageID)
}
// deleteMessageTgBot deletes a message from the chat.
func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
if bot == nil {
return
}
params := telego.DeleteMessageParams{
ChatID: tu.ID(chatId),
MessageID: messageID,
}
if err := bot.DeleteMessage(context.Background(), &params); err != nil {
logger.Warning("Failed to delete message:", err)
} else {
logger.Info("Message deleted successfully")
}
}
// TestConnection verifies the bot token is valid and the API is reachable.
func (t *Tgbot) TestConnection() error {
tgBotMutex.Lock()
b := bot
tgBotMutex.Unlock()
if b == nil {
return fmt.Errorf("bot not initialized")
}
me, err := b.GetMe(context.Background())
if err != nil {
return fmt.Errorf("API unreachable: %w", err)
}
_ = me
return nil
}