mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-04 12:15:02 +03:00
e093854f5b
Typed commands edited the remembered menu message, which a user who cleared the chat can no longer see, and then deleted the command, so /start left the chat empty again. Typed commands (/start, /status, /usage, /clearall) now send a new screen and then remove the old menu, or retire its buttons when it is past Telegram's delete window; button taps still edit in place. Closes #6997.
361 lines
13 KiB
Go
361 lines
13 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, delivery ...screenDelivery) {
|
|
if level := t.levelOf(userID); level != levelAdmin {
|
|
t.screenClientHome(chatID, userID, delivery...)
|
|
return
|
|
}
|
|
body := t.homeSummary()
|
|
t.renderScreen(chatID, t.newScreen("main", body, t.homeRows()...), delivery...)
|
|
}
|
|
|
|
// 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, delivery ...screenDelivery) {
|
|
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...), delivery...)
|
|
}
|
|
|
|
// 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, delivery ...screenDelivery) {
|
|
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...), delivery...)
|
|
}
|
|
|
|
// 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", 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 previous and current banned-IP logs with hide buttons.
|
|
func (t *Tgbot) sendBanLogsFile(chatID int64) {
|
|
bot := liveBot()
|
|
if bot == nil {
|
|
return
|
|
}
|
|
hasLogs := false
|
|
for _, path := range []string{xray.GetIPLimitBannedPrevLogPath(), xray.GetIPLimitBannedLogPath()} {
|
|
// Scope cleanup to each file so neither the handle nor the timeout lives
|
|
// through the next upload.
|
|
func() {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
logger.Warning("Failed to open the ban log:", path, "-", err)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
logger.Warning("Failed to stat the ban log:", path, "-", err)
|
|
return
|
|
}
|
|
if !info.Mode().IsRegular() || info.Size() == 0 {
|
|
logger.Warning("Ban log is empty or not a regular file, not uploading:", path)
|
|
return
|
|
}
|
|
// An upload failure does not mean the log was empty or missing.
|
|
hasLogs = true
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
_, err = bot.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:", path, "-", err)
|
|
}
|
|
}()
|
|
}
|
|
if !hasLogs {
|
|
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
|
|
}
|
|
}
|