mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 15:25:01 +03:00
15fee2a035
The bot answered every action with a new message, so a chat filled with stale menus whose buttons still acted, and long client or inbound lists were paged through dozens of buttons. Each chat now has one live screen that is edited in place: the menu is grouped into Server, Clients, Traffic and Maintenance, lists open through inline search, the add-client flow starts from a draft card, QR codes arrive as one labelled sheet, and repeated notices collapse into one with a counter. A client's /start and /usage show only that client's own links and usage, groups are refused, and the settings page warns when the bot's inline mode is off. Admin topics, the indexed Telegram lookup and every locale, Slovak included, carry over.
245 lines
6.8 KiB
Go
245 lines
6.8 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"
|
|
)
|
|
|
|
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) {
|
|
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
|
|
}
|