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) } }