mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
fac9009398
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.
247 lines
6.9 KiB
Go
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, ¶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<<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(), ¶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
|
|
}
|