Files
3x-ui/internal/web/service/tgbot/tgbot_report.go
T
Tuyến Trần a403faee23 feat(tgbot): route admin notifications to a forum topic (#6879)
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.
2026-10-24 05:55:55 +02:00

514 lines
17 KiB
Go

package tgbot
import (
"context"
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// SendReport sends a periodic report to admin chats.
func (t *Tgbot) SendReport() {
runTime, err := t.settingService.GetTgbotRuntime()
if err == nil && len(runTime) > 0 {
msg := ""
msg += t.I18nBot("tgbot.messages.report", "RunTime=="+runTime)
msg += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
t.SendMsgToTgbotAdmins(msg)
}
info := t.sendServerUsage()
t.SendMsgToTgbotAdmins(info)
t.sendExhaustedToAdmins()
t.notifyExhausted()
backupEnable, err := t.settingService.GetTgBotBackup()
if err == nil && backupEnable {
t.SendBackupToAdmins()
}
}
// SendBackupToAdmins sends a database backup to admin chats.
func (t *Tgbot) SendBackupToAdmins() {
if !t.IsRunning() {
return
}
dbData, err := t.serverService.GetDb()
if err != nil {
logger.Error("Error in getting db backup: ", err)
}
dbFilename := t.serverService.BackupFilename("")
admins := adminSnapshot()
for i, adminId := range admins {
t.sendBackupData(adminId, dbData, dbFilename)
// Add delay between sends to avoid Telegram rate limits
if i < len(admins)-1 {
time.Sleep(1 * time.Second)
}
}
}
// sendExhaustedToAdmins sends notifications about exhausted clients to admins.
func (t *Tgbot) sendExhaustedToAdmins() {
if !t.IsRunning() {
return
}
for _, adminId := range adminSnapshot() {
t.getExhausted(adminId)
}
}
// getServerUsage retrieves and formats server usage information.
func (t *Tgbot) getServerUsage(chatId int64, messageID ...int) string {
info := t.prepareServerUsageInfo()
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("usage_refresh"))))
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], info, keyboard)
} else {
t.SendMsgToTgbot(chatId, info, keyboard)
}
return info
}
// Send server usage without an inline keyboard
func (t *Tgbot) sendServerUsage() string {
info := t.prepareServerUsageInfo()
return info
}
// prepareServerUsageInfo prepares the server usage information string.
func (t *Tgbot) prepareServerUsageInfo() string {
// Check if we have cached data first
if cachedStats, found := t.getCachedServerStats(); found {
return cachedStats
}
info, ipv4, ipv6 := "", "", ""
// get latest status of server with caching
if cachedStatus, found := t.getCachedStatus(); found {
t.lastStatus = cachedStatus
} else {
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
t.setCachedStatus(t.lastStatus)
}
var onlines []string
if process := service.XrayProcess(); process != nil {
onlines = process.GetOnlineClients()
}
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
info += t.I18nBot("tgbot.messages.xrayVersion", "XrayVersion=="+fmt.Sprint(t.lastStatus.Xray.Version))
// get ip address
netInterfaces, err := net.Interfaces()
if err != nil {
logger.Error("net.Interfaces failed, err: ", err.Error())
info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown"))
info += "\r\n"
} else {
for i := range netInterfaces {
if (netInterfaces[i].Flags & net.FlagUp) != 0 {
addrs, _ := netInterfaces[i].Addrs()
for _, address := range addrs {
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
ipv4 += ipnet.IP.String() + " "
} else if ipnet.IP.To16() != nil && !ipnet.IP.IsLinkLocalUnicast() {
ipv6 += ipnet.IP.String() + " "
}
}
}
}
}
info += t.I18nBot("tgbot.messages.ipv4", "IPv4=="+ipv4)
info += t.I18nBot("tgbot.messages.ipv6", "IPv6=="+ipv6)
}
info += t.I18nBot("tgbot.messages.serverUpTime", "UpTime=="+strconv.FormatUint(t.lastStatus.Uptime/86400, 10), "Unit=="+t.I18nBot("tgbot.days"))
info += t.I18nBot("tgbot.messages.serverLoad", "Load1=="+strconv.FormatFloat(t.lastStatus.Loads[0], 'f', 2, 64), "Load2=="+strconv.FormatFloat(t.lastStatus.Loads[1], 'f', 2, 64), "Load3=="+strconv.FormatFloat(t.lastStatus.Loads[2], 'f', 2, 64))
info += t.I18nBot("tgbot.messages.serverMemory", "Current=="+common.FormatTraffic(int64(t.lastStatus.Mem.Current)), "Total=="+common.FormatTraffic(int64(t.lastStatus.Mem.Total)))
info += t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(len(onlines)))
info += t.I18nBot("tgbot.messages.tcpCount", "Count=="+strconv.Itoa(t.lastStatus.TcpCount))
info += t.I18nBot("tgbot.messages.udpCount", "Count=="+strconv.Itoa(t.lastStatus.UdpCount))
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent+t.lastStatus.NetTraffic.Recv)), "Upload=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent)), "Download=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Recv)))
info += t.I18nBot("tgbot.messages.xrayStatus", "State=="+fmt.Sprint(t.lastStatus.Xray.State))
// Cache the complete server stats
t.setCachedServerStats(info)
return info
}
// UserLoginNotify publishes a login event to the event bus.
func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" {
logger.Warning("UserLoginNotify failed, invalid info!")
return
}
if EventBus == nil {
return
}
status := "fail"
if attempt.Status == LoginSuccess {
status = "success"
}
EventBus.Publish(eventbus.Event{
Type: eventbus.EventLoginAttempt,
Source: attempt.IP,
Data: &eventbus.LoginEventData{
Username: attempt.Username,
IP: attempt.IP,
Time: attempt.Time,
Status: status,
Reason: attempt.Reason,
},
})
}
// getExhausted retrieves and sends information about exhausted clients.
func (t *Tgbot) getExhausted(chatId int64) {
trDiff := int64(0)
exDiff := int64(0)
now := time.Now().Unix() * 1000
var exhaustedInbounds []model.Inbound
var exhaustedClients []xray.ClientTraffic
var disabledInbounds []model.Inbound
var disabledClients []xray.ClientTraffic
TrafficThreshold, err := t.settingService.GetTrafficDiff()
if err == nil && TrafficThreshold > 0 {
trDiff = int64(TrafficThreshold) * 1073741824
}
ExpireThreshold, err := t.settingService.GetExpireDiff()
if err == nil && ExpireThreshold > 0 {
exDiff = int64(ExpireThreshold) * 86400000
}
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("Unable to load Inbounds", err)
}
seenClients := make(map[string]bool)
for _, inbound := range inbounds {
if inbound.Enable {
if (inbound.ExpiryTime > 0 && (inbound.ExpiryTime-now < exDiff)) ||
(inbound.Total > 0 && (inbound.Total-(inbound.Up+inbound.Down) < trDiff)) {
exhaustedInbounds = append(exhaustedInbounds, *inbound)
}
if len(inbound.ClientStats) > 0 {
for _, client := range inbound.ClientStats {
if seenClients[client.Email] {
continue
}
seenClients[client.Email] = true
if client.Enable {
if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) ||
(client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) {
exhaustedClients = append(exhaustedClients, client)
}
} else {
disabledClients = append(disabledClients, client)
}
}
}
} else {
disabledInbounds = append(disabledInbounds, *inbound)
}
}
// Inbounds
output := ""
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds"))
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledInbounds)))
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedInbounds)))
if len(exhaustedInbounds) > 0 {
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.inbounds"))
for _, inbound := range exhaustedInbounds {
output += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
output += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
output += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))
if inbound.ExpiryTime == 0 {
output += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
} else {
output += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
}
output += "\r\n"
}
}
// Clients
exhaustedCC := len(exhaustedClients)
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients)))
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedCC))
if exhaustedCC > 0 {
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.clients"))
var buttons []telego.InlineKeyboardButton
for _, traffic := range exhaustedClients {
output += t.clientInfoMsg(&traffic, true, false, false, true, true, false)
output += "\r\n"
buttons = append(buttons, tu.InlineKeyboardButton(traffic.Email).WithCallbackData(t.encodeQuery("client_get_usage "+traffic.Email)))
}
cols := 0
if exhaustedCC < 11 {
cols = 1
} else {
cols = 2
}
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
t.SendMsgToTgbot(chatId, output, keyboard)
} else {
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
t.SendMsgToTgbot(chatId, output)
}
}
// notifyExhausted sends notifications for exhausted clients.
func (t *Tgbot) notifyExhausted() {
trDiff := int64(0)
exDiff := int64(0)
now := time.Now().Unix() * 1000
TrafficThreshold, err := t.settingService.GetTrafficDiff()
if err == nil && TrafficThreshold > 0 {
trDiff = int64(TrafficThreshold) * 1073741824
}
ExpireThreshold, err := t.settingService.GetExpireDiff()
if err == nil && ExpireThreshold > 0 {
exDiff = int64(ExpireThreshold) * 86400000
}
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("Unable to load Inbounds", err)
}
chatIDsDone := make(map[int64]struct{})
for _, inbound := range inbounds {
if inbound.Enable {
if len(inbound.ClientStats) > 0 {
clients, err := t.inboundService.ListClientsForInbound(inbound.Id)
if err == nil {
for _, client := range clients {
if client.TgID != 0 {
chatID := client.TgID
if _, done := chatIDsDone[chatID]; !done && !checkAdmin(chatID) {
chatIDsDone[chatID] = struct{}{}
var disabledClients []xray.ClientTraffic
var exhaustedClients []xray.ClientTraffic
traffics, err := t.inboundService.GetClientTrafficTgBot(client.TgID)
if err != nil {
logger.Warningf("Unable to load Telegram client traffic for %d: %v", chatID, err)
}
if err == nil && len(traffics) > 0 {
var output strings.Builder
output.WriteString(t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients")))
for _, traffic := range traffics {
if traffic.Enable {
if (traffic.ExpiryTime > 0 && (traffic.ExpiryTime-now < exDiff)) ||
(traffic.Total > 0 && (traffic.Total-(traffic.Up+traffic.Down) < trDiff)) {
exhaustedClients = append(exhaustedClients, *traffic)
}
} else {
disabledClients = append(disabledClients, *traffic)
}
}
if len(exhaustedClients) > 0 {
output.WriteString(t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients))))
if len(disabledClients) > 0 {
output.WriteString(t.I18nBot("tgbot.clients"))
output.WriteString(":\r\n")
for _, traffic := range disabledClients {
output.WriteString(" ")
output.WriteString(traffic.Email)
}
output.WriteString("\r\n")
}
output.WriteString("\r\n")
output.WriteString(t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedClients))))
for _, traffic := range exhaustedClients {
output.WriteString(t.clientInfoMsg(&traffic, true, false, false, true, true, false))
output.WriteString("\r\n")
}
t.SendMsgToTgbot(chatID, output.String())
}
}
}
}
}
}
}
}
}
}
// onlineClients retrieves and sends information about online clients.
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
process := service.XrayProcess()
if process == nil || !process.IsRunning() {
return
}
onlines := process.GetOnlineClients()
onlinesCount := len(onlines)
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("onlines_refresh"))))
if onlinesCount > 0 {
var buttons []telego.InlineKeyboardButton
for _, online := range onlines {
label := online
if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" {
label = online + " - " + inbound.Remark
}
buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(t.encodeQuery("client_get_usage "+online)))
}
cols := 0
if onlinesCount < 21 {
cols = 2
} else if onlinesCount < 61 {
cols = 3
} else {
cols = 4
}
keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, tu.InlineKeyboardCols(cols, buttons...)...)
}
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], output, keyboard)
} else {
t.SendMsgToTgbot(chatId, output, keyboard)
}
}
// sendBackup sends a backup of the database and configuration files.
func (t *Tgbot) sendBackup(chatId int64) {
dbData, err := t.serverService.GetDb()
if err != nil {
logger.Error("Error in getting db backup: ", err)
}
t.sendBackupData(chatId, dbData, t.serverService.BackupFilename(""))
}
func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
t.SendMsgToTgbot(chatId, output)
// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
if dbData != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(dbData, dbFilename),
)
_, err := bot.SendDocument(ctx, inAdminTopic(document))
cancel()
if err != nil {
logger.Error("Error in uploading backup: ", err)
}
}
// Small delay between file sends
time.Sleep(500 * time.Millisecond)
// Send config.json backup
file, err := os.Open(xray.GetConfigPath())
if err == nil {
defer file.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
document := tu.Document(
tu.ID(chatId),
tu.File(file),
)
_, err = bot.SendDocument(ctx, inAdminTopic(document))
if err != nil {
logger.Error("Error in uploading config.json: ", err)
}
} else {
logger.Error("Error in opening config.json file for backup: ", err)
}
}
// sendBanLogs sends the ban logs to the specified chat.
func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
if dt {
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.SendMsgToTgbot(chatId, output)
}
file, err := os.Open(xray.GetIPLimitBannedPrevLogPath())
if err == nil {
// Check if the file is non-empty before attempting to upload
fileInfo, _ := file.Stat()
if fileInfo.Size() > 0 {
document := tu.Document(
tu.ID(chatId),
tu.File(file),
)
_, err = bot.SendDocument(context.Background(), inAdminTopic(document))
if err != nil {
logger.Error("Error in uploading IPLimitBannedPrevLog: ", err)
}
} else {
logger.Warning("IPLimitBannedPrevLog file is empty, not uploading.")
}
file.Close()
} else {
logger.Error("Error in opening IPLimitBannedPrevLog file for backup: ", err)
}
file, err = os.Open(xray.GetIPLimitBannedLogPath())
if err == nil {
// Check if the file is non-empty before attempting to upload
fileInfo, _ := file.Stat()
if fileInfo.Size() > 0 {
document := tu.Document(
tu.ID(chatId),
tu.File(file),
)
_, err = bot.SendDocument(context.Background(), inAdminTopic(document))
if err != nil {
logger.Error("Error in uploading IPLimitBannedLog: ", err)
}
} else {
logger.Warning("IPLimitBannedLog file is empty, not uploading.")
}
file.Close()
} else {
logger.Error("Error in opening IPLimitBannedLog file for backup: ", err)
}
}