mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 23:05:01 +03:00
a403faee23
Admins who run the bot in a forum supergroup got every notification in the General topic. An admin chat ID may now carry a topic, chatId:topicId, and the bot's admin messages and reports go to that topic. Repeated chats collapse into one entry and a second, different topic for the same chat is rejected, in the panel and in the docs setup helper alike. The chat ID hint in every locale and the English, Persian, Russian and Chinese guides describe the form.
319 lines
11 KiB
Go
319 lines
11 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)
|
|
}
|
|
|
|
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{lines[0]}
|
|
for _, line := range lines[1:] {
|
|
last := len(pages) - 1
|
|
if len(pages[last])+len("\r\n")+len(line) > limit {
|
|
pages = append(pages, line)
|
|
continue
|
|
}
|
|
pages[last] += "\r\n" + line
|
|
}
|
|
return pages
|
|
}
|
|
|
|
// SendMsgToTgbot sends a message to the Telegram bot with optional reply markup.
|
|
func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) {
|
|
if !t.IsRunning() {
|
|
return
|
|
}
|
|
|
|
if msg == "" {
|
|
logger.Info("[tgbot] message is empty!")
|
|
return
|
|
}
|
|
|
|
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)
|
|
_, err := bot.SendMessage(ctx, ¶ms)
|
|
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) {
|
|
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 := bot.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 := bot.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 := bot.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")
|
|
}
|
|
|
|
// 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),
|
|
MessageThreadID: adminTopic(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(), ¶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 {
|
|
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
|
|
}
|