Files
3x-ui/internal/web/service/tgbot/tgbot_send.go
T
MHSanaei fac9009398 fix(tgbot): escape template parameters once in I18nBot
Every bot message is sent HTML-parsed while the intended markup lives
in the translation templates, yet the values filled into them were
escaped only at some call sites. Remarks and e-mails, which a node
chooses for the inbounds it reports, reached the scheduled report and
the client info unescaped and turned into markup, and the reports that
escaped their whole text instead showed the templates' own tags.

I18nBot now escapes every parameter value, the call sites drop their
own escaping and the whole-report escapes, the two values concatenated
outside a template are escaped where they are written, and callback
answers, which are plain text, are unescaped again.
2026-11-01 08:58:43 +01:00

247 lines
6.9 KiB
Go

package tgbot
import (
"context"
"fmt"
"html"
"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, &params)
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<<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)
}
}
return lastID
}
// SendMsgToTgbotAdmins sends a message to all admin Telegram chats.
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
admins := adminSnapshot()
if len(replyMarkup) > 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) {
// Callback answers are plain text, so the HTML entities I18nBot adds are undone.
params := telego.AnswerCallbackQueryParams{
CallbackQueryID: id,
Text: html.UnescapeString(message),
}
if err := liveBot().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 := liveBot().EditMessageReplyMarkup(context.Background(), &params); 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(), &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")
}
// 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(), &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 {
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
}