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" ) const telegramPageLimit = 2000 func pageMessage(message string, limit int) []string { if len(message) <= limit { return []string{message} } pages := make([]string, 0) for block := range strings.SplitSeq(message, "\r\n\r\n") { for _, page := range splitMessageLines(block, limit) { last := len(pages) - 1 if last >= 0 && len(pages[last])+len("\r\n\r\n")+len(page) <= limit { pages[last] += "\r\n\r\n" + page continue } pages = append(pages, page) } } if len(pages) > 0 && strings.TrimSpace(pages[len(pages)-1]) == "" { pages = pages[:len(pages)-1] } return pages } func splitMessageLines(block string, limit int) []string { if len(block) <= limit { return []string{block} } lines := strings.Split(block, "\r\n") pages := []string{} for _, line := range lines { // A single link can exceed a caption on its own; Telegram counts bytes, // so such a line is split rather than kept whole and rejected. for len(line) > limit { pages = append(pages, line[:limit]) line = line[limit:] } if len(pages) == 0 { pages = append(pages, line) continue } last := len(pages) - 1 if len(pages[last])+len("\r\n")+len(line) > limit { pages = append(pages, line) continue } pages[last] += "\r\n" + line } if len(pages) == 0 { pages = append(pages, "") } return pages } // SendMsgToTgbot sends a message with optional reply markup and returns the id // of the last one delivered, or 0; only the broadcast prompt needs it. func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) int { if !t.IsRunning() { return 0 } if msg == "" { logger.Info("[tgbot] message is empty!") return 0 } lastID := 0 allMessages := pageMessage(msg, telegramPageLimit) for n, message := range allMessages { params := telego.SendMessageParams{ ChatID: tu.ID(chatId), MessageThreadID: adminTopic(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) sent, err := liveBot().SendMessage(ctx, ¶ms) cancel() if err == nil { if sent != nil { lastID = sent.MessageID } 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< 0 { for _, adminId := range admins { t.SendMsgToTgbot(adminId, msg, replyMarkup[0]) } } else { for _, adminId := range admins { 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 := liveBot().AnswerCallbackQuery(context.Background(), ¶ms); 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 := liveBot().EditMessageReplyMarkup(context.Background(), ¶ms); err != nil { if isTelegramNotModifiedError(err) { logger.Debug("Telegram reply markup unchanged, skipping edit") return } 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 := liveBot().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") } // sendPagedNotice posts one notice per page, each dismissable. Telegram rejects // a message over 4096 characters whole, so a per-client report has to be split. func (t *Tgbot) sendPagedNotice(chatID int64, text string) { for _, chunk := range pageMessage(text, telegramPageLimit) { t.sendNoticeReturningID(chatID, chunk) } } // deleteMessageTgBot deletes a message from the chat. func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) { if liveBot() == nil { return } params := telego.DeleteMessageParams{ ChatID: tu.ID(chatId), MessageID: messageID, } if err := liveBot().DeleteMessage(context.Background(), ¶ms); 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 { b := liveBot() 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 }