mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 23:35:00 +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.
339 lines
12 KiB
Go
339 lines
12 KiB
Go
package tgbot
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"html"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"github.com/mymmrac/telego"
|
|
tu "github.com/mymmrac/telego/telegoutil"
|
|
)
|
|
|
|
// Views for the read-only screens. Each one is a pure "read state, return a
|
|
// screen" function; the router only decides which view to show.
|
|
|
|
// screenHome is the admin's landing screen: the numbers you open the bot for,
|
|
// then the menu, with the bot's avatar until the panel can host its own artwork.
|
|
func (t *Tgbot) screenHome(chatID, userID int64) {
|
|
if level := t.levelOf(userID); level != levelAdmin {
|
|
t.screenClientHome(chatID, userID)
|
|
return
|
|
}
|
|
body := t.homeSummary()
|
|
t.renderScreen(chatID, t.newScreen("main", body, t.homeRows()...))
|
|
}
|
|
|
|
// screenClientHome is a client's own entry point: its usage, then links and QR.
|
|
// /start once gave clients the ADMIN menu (panel ops, every client's email).
|
|
func (t *Tgbot) screenClientHome(chatID, userID int64) {
|
|
traffics, err := t.inboundService.GetClientTrafficTgBot(userID)
|
|
if err != nil || len(traffics) == 0 {
|
|
t.sendNotice(chatID, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(userID, 10)))
|
|
return
|
|
}
|
|
var body strings.Builder
|
|
for _, traffic := range traffics {
|
|
body.WriteString(t.clientInfoMsg(traffic, true, true, true, true, true, true))
|
|
body.WriteString("\r\n")
|
|
}
|
|
// One subscription needs no choice (links and QR are right here); several are
|
|
// one row each. Either way this is the client's top level, so no back button.
|
|
var rows [][]telego.InlineKeyboardButton
|
|
if len(traffics) == 1 {
|
|
rows = append(rows, tu.InlineKeyboardRow(
|
|
t.btn("tgbot.buttons.links", "client_sub_links"),
|
|
t.btn("tgbot.buttons.qrCode", "client_qr_links"),
|
|
))
|
|
} else {
|
|
for _, traffic := range traffics {
|
|
rows = append(rows, tu.InlineKeyboardRow(
|
|
t.btn2(traffic.Email, t.encodeQuery("client_sub_links "+traffic.Email)),
|
|
))
|
|
}
|
|
}
|
|
t.renderScreen(chatID, t.rootScreen("main", body.String(), rows...))
|
|
}
|
|
|
|
// homeSummary is the panel overview line used by the menu and the report, so
|
|
// the two can never disagree about the host or the version.
|
|
func (t *Tgbot) homeSummary() string {
|
|
inbounds, err := t.inboundService.GetAllInbounds()
|
|
if err != nil {
|
|
logger.Warning("Unable to load inbounds for the menu:", err)
|
|
}
|
|
clients := 0
|
|
online := 0
|
|
traffic := int64(0)
|
|
for _, ib := range inbounds {
|
|
clients += len(ib.ClientStats)
|
|
traffic += ib.Up + ib.Down
|
|
}
|
|
if process := xrayProcessOrNil(); process != nil {
|
|
online = len(process.GetOnlineClients())
|
|
}
|
|
body := ""
|
|
body += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
|
body += t.I18nBot("tgbot.messages.version", "Version=="+panelVersion())
|
|
body += t.I18nBot("tgbot.messages.inboundsCount", "Count=="+strconv.Itoa(len(inbounds)))
|
|
body += t.I18nBot("tgbot.messages.clientsCount", "Count=="+strconv.Itoa(clients))
|
|
body += t.I18nBot("tgbot.messages.onlinesCount", "Count=="+strconv.Itoa(online))
|
|
body += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(traffic),
|
|
"Upload=="+common.FormatTraffic(traffic), "Download=="+common.FormatTraffic(0))
|
|
return body
|
|
}
|
|
|
|
// screenServer renders the server-usage screen.
|
|
func (t *Tgbot) screenServer(chatID int64) {
|
|
body := strings.TrimRight(t.prepareServerUsageInfo(), "\r\n")
|
|
rows := [][]telego.InlineKeyboardButton{
|
|
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbServer)),
|
|
t.backRow(),
|
|
}
|
|
t.renderScreen(chatID, t.newScreen("status", body, rows...))
|
|
}
|
|
|
|
// screenOnlines lists who is connected right now; client names are plain text
|
|
// (the inline browser takes over the per-client pick next).
|
|
func (t *Tgbot) screenOnlines(chatID int64) {
|
|
process := xrayProcessOrNil()
|
|
if process == nil {
|
|
t.renderScreen(chatID, t.newScreen("onlines", t.I18nBot("tgbot.wentWrong"),
|
|
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbOnlines)), t.backRow()))
|
|
return
|
|
}
|
|
onlines := process.GetOnlineClients()
|
|
body := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+strconv.Itoa(len(onlines)))
|
|
for _, online := range onlines {
|
|
label := online
|
|
if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" {
|
|
label = online + " · " + inbound.Remark
|
|
}
|
|
body += "• " + html.EscapeString(label) + "\n"
|
|
}
|
|
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
rows := [][]telego.InlineKeyboardButton{
|
|
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbOnlines)),
|
|
t.backRow(),
|
|
}
|
|
t.renderScreen(chatID, t.newScreen("onlines", body, rows...))
|
|
}
|
|
|
|
// screenDeplete renders the "running out" screen; the scheduled report and the
|
|
// button share this text, so a client is never listed twice with a different story.
|
|
func (t *Tgbot) screenDeplete(chatID int64) {
|
|
body := t.depleteReport()
|
|
rows := [][]telego.InlineKeyboardButton{
|
|
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbDeplete)),
|
|
t.backRow(),
|
|
}
|
|
t.renderScreen(chatID, t.newScreen("deplete", body, rows...))
|
|
}
|
|
|
|
// depleteReport builds the exhausted/disabled summary as plain text.
|
|
func (t *Tgbot) depleteReport() string {
|
|
trDiff, exDiff := t.depletionThresholds()
|
|
now := time.Now().Unix() * 1000
|
|
|
|
inbounds, err := t.inboundService.GetAllInbounds()
|
|
if err != nil {
|
|
logger.Warning("Unable to load inbounds for the depletion report:", err)
|
|
}
|
|
exhaustedInbounds, disabledInbounds := 0, 0
|
|
exhaustedClients, disabledClients := 0, 0
|
|
seen := map[string]bool{}
|
|
var clientLines strings.Builder
|
|
|
|
for _, inbound := range inbounds {
|
|
if !inbound.Enable {
|
|
disabledInbounds++
|
|
continue
|
|
}
|
|
if (inbound.ExpiryTime > 0 && inbound.ExpiryTime-now < exDiff) ||
|
|
(inbound.Total > 0 && inbound.Total-(inbound.Up+inbound.Down) < trDiff) {
|
|
exhaustedInbounds++
|
|
}
|
|
for _, client := range inbound.ClientStats {
|
|
if seen[client.Email] {
|
|
continue
|
|
}
|
|
seen[client.Email] = true
|
|
if !client.Enable {
|
|
disabledClients++
|
|
continue
|
|
}
|
|
if (client.ExpiryTime > 0 && client.ExpiryTime-now < exDiff) ||
|
|
(client.Total > 0 && client.Total-(client.Up+client.Down) < trDiff) {
|
|
exhaustedClients++
|
|
clientLines.WriteString(t.clientInfoMsg(&client, true, false, false, true, true, false))
|
|
clientLines.WriteString("\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
body := ""
|
|
body += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds"))
|
|
body += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(disabledInbounds))
|
|
body += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedInbounds))
|
|
body += "\n"
|
|
body += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))
|
|
body += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(disabledClients))
|
|
body += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedClients))
|
|
if clientLines.Len() > 0 {
|
|
body += "\n" + html.EscapeString(clientLines.String())
|
|
}
|
|
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
return body
|
|
}
|
|
|
|
// depletionThresholds reads the two "running out" windows from the settings.
|
|
func (t *Tgbot) depletionThresholds() (traffic, expire int64) {
|
|
if v, err := t.settingService.GetTrafficDiff(); err == nil && v > 0 {
|
|
traffic = int64(v) * 1073741824
|
|
}
|
|
if v, err := t.settingService.GetExpireDiff(); err == nil && v > 0 {
|
|
expire = int64(v) * 86400000
|
|
}
|
|
return traffic, expire
|
|
}
|
|
|
|
// screenReport renders the sorted traffic-usage report. The screen pages it,
|
|
// so a large panel gets one message with page controls, not a message flood.
|
|
func (t *Tgbot) screenReport(chatID int64) {
|
|
emails, err := t.inboundService.GetAllEmails()
|
|
if err != nil {
|
|
t.renderScreen(chatID, t.newScreen("reports", t.I18nBot("tgbot.answers.errorOperation"), t.backRow()))
|
|
return
|
|
}
|
|
validEmails, missingEmails, err := t.inboundService.FilterAndSortClientEmails(emails)
|
|
if err != nil {
|
|
t.renderScreen(chatID, t.newScreen("reports", t.I18nBot("tgbot.answers.errorOperation"), t.backRow()))
|
|
return
|
|
}
|
|
|
|
var report strings.Builder
|
|
for _, email := range validEmails {
|
|
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
|
|
if err != nil || traffic == nil {
|
|
fmt.Fprintf(&report, "📧 %s\n%s\n", html.EscapeString(email), t.I18nBot("tgbot.noResult"))
|
|
continue
|
|
}
|
|
report.WriteString(t.clientInfoMsg(traffic, false, false, false, false, true, false))
|
|
report.WriteString("\n")
|
|
}
|
|
for _, email := range missingEmails {
|
|
fmt.Fprintf(&report, "📧 %s\n%s\n", html.EscapeString(email), t.I18nBot("tgbot.noResult"))
|
|
}
|
|
rows := [][]telego.InlineKeyboardButton{
|
|
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbReport)),
|
|
t.backRow(),
|
|
}
|
|
t.renderScreen(chatID, t.newScreen("reports", html.EscapeString(report.String()), rows...))
|
|
}
|
|
|
|
// screenCommands explains the classic commands; kept because muscle memory is
|
|
// real, but the menu no longer advertises them.
|
|
func (t *Tgbot) screenCommands(chatID int64, isAdmin bool) {
|
|
key := "tgbot.commands.helpClientCommands"
|
|
if isAdmin {
|
|
key = "tgbot.commands.helpAdminCommands"
|
|
}
|
|
t.renderScreen(chatID, t.newScreen("commands", t.I18nBot(key), t.backRow()))
|
|
}
|
|
|
|
// sendBackupScreen delivers the backup and summary as ONE message (file plus
|
|
// caption); a summary too long for a caption is sent after the file.
|
|
func (t *Tgbot) sendBackupScreen(chatID int64) {
|
|
dbData, err := t.serverService.GetDb()
|
|
if err != nil {
|
|
logger.Error("Error in getting db backup:", err)
|
|
}
|
|
summary := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
|
summary += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
|
|
if dbData == nil {
|
|
t.sendNotice(chatID, summary)
|
|
return
|
|
}
|
|
// The summary and the depletion list share the file's caption: one message
|
|
// carries the backup and what it is for.
|
|
body := summary + "\n\n" + t.depleteReport()
|
|
caption := trimCaption(body, botCaptionLimit)
|
|
if t.sendDocumentWithCaption(chatID, dbData, t.serverService.BackupFilename(""), caption) {
|
|
// The generated config travels with the DB on both backup paths: an
|
|
// operator restoring from the file would otherwise lose it silently.
|
|
if config, err := os.ReadFile(xray.GetConfigPath()); err == nil {
|
|
t.sendDocumentWithCaption(chatID, config, "config.json", "")
|
|
}
|
|
if rest := strings.TrimSpace(strings.TrimPrefix(body, caption)); rest != "" {
|
|
// Rare: the summary outgrew a caption. The file already landed.
|
|
t.sendNotice(chatID, rest)
|
|
}
|
|
return
|
|
}
|
|
// The upload failed: the summary is still worth sending.
|
|
t.sendNotice(chatID, summary)
|
|
}
|
|
|
|
// screenBanLogs sends the ban log file as a notice, never as a screen.
|
|
func (t *Tgbot) screenBanLogs(chatID int64) {
|
|
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
|
output += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
t.sendNotice(chatID, output)
|
|
t.sendBanLogsFile(chatID)
|
|
}
|
|
|
|
// sendNotice posts a plain bot message that is explicitly not a screen: it
|
|
// carries the hide button, and it never becomes the chat's tracked screen.
|
|
func (t *Tgbot) sendNotice(chatID int64, text string) {
|
|
t.sendNoticeReturningID(chatID, text)
|
|
}
|
|
|
|
// sendNoticeReturningID posts one dismissable message and hands back its id, so
|
|
// the caller can replace it later instead of leaving copies behind.
|
|
func (t *Tgbot) sendNoticeReturningID(chatID int64, text string) int {
|
|
if liveBot() == nil || text == "" {
|
|
return 0
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
sent, err := liveBot().SendMessage(ctx, &telego.SendMessageParams{
|
|
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: text, ParseMode: "HTML", ReplyMarkup: t.hideButton(),
|
|
})
|
|
if err != nil {
|
|
logger.Warning("Failed to send a notice:", err)
|
|
return 0
|
|
}
|
|
return sent.MessageID
|
|
}
|
|
|
|
// sendBanLogsFile delivers the banned-IP log as a notice with a hide button.
|
|
func (t *Tgbot) sendBanLogsFile(chatID int64) {
|
|
file, err := os.Open(xray.GetIPLimitBannedPrevLogPath())
|
|
if err != nil {
|
|
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
if liveBot() == nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
_, err = liveBot().SendDocument(ctx, &telego.SendDocumentParams{
|
|
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID),
|
|
Document: tu.File(file),
|
|
ReplyMarkup: t.hideButton(),
|
|
})
|
|
if err != nil {
|
|
logger.Warning("Failed to upload the ban log:", err)
|
|
}
|
|
}
|