mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
1c748adadf
A subId is the only selector of a subscription's inbounds and external links, so a blank one matched every client stored without a subId, as clients embedded through inbounds/add or inbounds/import are. The Telegram bot authorised a client by email and then looked its links up by that blank subId, handing a bound user the working links of every other subId-less client. A blank subId now resolves to nothing in the subscription service, and the bot builds a subId-less client's own links by email, as the panel's client page does.
471 lines
15 KiB
Go
471 lines
15 KiB
Go
package tgbot
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"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"
|
|
)
|
|
|
|
// BuildClientDraftMessage builds a protocol-neutral summary of the in-progress
|
|
// client (email, attached inbounds, traffic limit, expiry, ip limit, comment)
|
|
// shown in the multi-inbound add flow. Per-protocol secrets (UUID, password,
|
|
// flow, method) are generated by fillProtocolDefaults on submit, so the bot
|
|
// never has to track them per inbound itself.
|
|
func (t *Tgbot) BuildClientDraftMessage(draft *clientDraft) string {
|
|
now := time.Now().UnixMilli()
|
|
|
|
expiry := ""
|
|
switch {
|
|
case draft.expiryTime == 0:
|
|
expiry = t.I18nBot("tgbot.unlimited")
|
|
case draft.expiryTime < 0:
|
|
expiry = fmt.Sprintf("%d %s", draft.expiryTime/-86400000, t.I18nBot("tgbot.days"))
|
|
default:
|
|
diff := draft.expiryTime - now
|
|
if diff > 172800000 {
|
|
expiry = time.UnixMilli(draft.expiryTime).Format("2006-01-02 15:04:05")
|
|
} else {
|
|
expiry = fmt.Sprintf("%d %s", diff/3600000, t.I18nBot("tgbot.hours"))
|
|
}
|
|
}
|
|
|
|
traffic := "♾️ Unlimited(Reset)"
|
|
if draft.totalGB > 0 {
|
|
traffic = common.FormatTraffic(draft.totalGB)
|
|
}
|
|
|
|
ipLimit := "♾️ Unlimited(Reset)"
|
|
if draft.limitIP > 0 {
|
|
ipLimit = fmt.Sprint(draft.limitIP)
|
|
}
|
|
|
|
attached := t.describeAttachedInbounds(draft.receiverInboundIDs)
|
|
if attached == "" {
|
|
attached = "—"
|
|
}
|
|
|
|
comment := draft.comment
|
|
if comment == "" {
|
|
comment = "—"
|
|
}
|
|
|
|
tgID := draft.tgID
|
|
if tgID == "" {
|
|
tgID = "—"
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString("📝 <b>New client draft</b>\r\n")
|
|
fmt.Fprintf(&b, "📧 Email: <code>%s</code>\r\n", html.EscapeString(draft.email))
|
|
fmt.Fprintf(&b, "🔗 Attached: %s\r\n", html.EscapeString(attached))
|
|
fmt.Fprintf(&b, "📊 Traffic: %s\r\n", traffic)
|
|
fmt.Fprintf(&b, "📅 Expire: %s\r\n", expiry)
|
|
fmt.Fprintf(&b, "🔢 IP limit: %s\r\n", ipLimit)
|
|
fmt.Fprintf(&b, "👤 TG user: %s\r\n", html.EscapeString(tgID))
|
|
fmt.Fprintf(&b, "💬 Comment: %s\r\n", html.EscapeString(comment))
|
|
return b.String()
|
|
}
|
|
|
|
// describeAttachedInbounds returns a short "remark1, remark2" list for the given
|
|
// inbound ids, falling back to "#id" when an inbound can't be loaded.
|
|
func (t *Tgbot) describeAttachedInbounds(ids []int) string {
|
|
if len(ids) == 0 {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
ib, err := t.inboundService.GetInbound(id)
|
|
if err != nil || ib == nil {
|
|
parts = append(parts, fmt.Sprintf("#%d", id))
|
|
continue
|
|
}
|
|
label := ib.Remark
|
|
if label == "" {
|
|
label = fmt.Sprintf("#%d", id)
|
|
}
|
|
parts = append(parts, label)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// SubmitAddClient sends the in-progress client to ClientService.Create with
|
|
// the full set of attached inbound ids. Per-inbound fillProtocolDefaults on
|
|
// the panel generates UUID/password/auth per protocol, so the bot only
|
|
// supplies the universal fields it actually collected.
|
|
func (t *Tgbot) SubmitAddClient(draft *clientDraft) (bool, error) {
|
|
inboundIDs := draft.receiverInboundIDs
|
|
if len(inboundIDs) == 0 && draft.receiverInboundID > 0 {
|
|
inboundIDs = []int{draft.receiverInboundID}
|
|
}
|
|
if len(inboundIDs) == 0 {
|
|
return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
}
|
|
|
|
tgIDInt, _ := strconv.ParseInt(draft.tgID, 10, 64)
|
|
client := model.Client{
|
|
Email: draft.email,
|
|
Enable: draft.enable,
|
|
LimitIP: draft.limitIP,
|
|
TotalGB: draft.totalGB,
|
|
ExpiryTime: draft.expiryTime,
|
|
SubID: draft.subID,
|
|
Comment: draft.comment,
|
|
Reset: draft.reset,
|
|
TgID: tgIDInt,
|
|
}
|
|
|
|
return t.clientService.Create(&t.inboundService, &service.ClientCreatePayload{
|
|
Client: client,
|
|
InboundIds: inboundIDs,
|
|
})
|
|
}
|
|
|
|
// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email
|
|
func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
|
// Resolve subId from client email
|
|
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
|
_ = traffic
|
|
if err != nil || client == nil {
|
|
return "", "", errors.New("client not found")
|
|
}
|
|
|
|
// Gather settings to construct absolute URLs
|
|
subURI, _ := t.settingService.GetSubURI()
|
|
subJsonURI, _ := t.settingService.GetSubJsonURI()
|
|
subDomain, _ := t.settingService.GetSubDomain()
|
|
subPort, _ := t.settingService.GetSubPort()
|
|
subPath, _ := t.settingService.GetSubPath()
|
|
subJsonPath, _ := t.settingService.GetSubJsonPath()
|
|
subJsonEnable, _ := t.settingService.GetSubJsonEnable()
|
|
subKeyFile, _ := t.settingService.GetSubKeyFile()
|
|
subCertFile, _ := t.settingService.GetSubCertFile()
|
|
|
|
tls := (subKeyFile != "" && subCertFile != "")
|
|
scheme := "http"
|
|
if tls {
|
|
scheme = "https"
|
|
}
|
|
|
|
// Fallbacks
|
|
if subDomain == "" {
|
|
// try panel domain, otherwise OS hostname
|
|
if d, err := t.settingService.GetWebDomain(); err == nil && d != "" {
|
|
subDomain = d
|
|
} else if hostname != "" {
|
|
subDomain = hostname
|
|
} else {
|
|
subDomain = "localhost"
|
|
}
|
|
}
|
|
|
|
host := subDomain
|
|
if (subPort == 443 && tls) || (subPort == 80 && !tls) {
|
|
// standard ports: no port in host
|
|
} else {
|
|
host = fmt.Sprintf("%s:%d", subDomain, subPort)
|
|
}
|
|
|
|
// Ensure paths
|
|
if !strings.HasPrefix(subPath, "/") {
|
|
subPath = "/" + subPath
|
|
}
|
|
if !strings.HasSuffix(subPath, "/") {
|
|
subPath = subPath + "/"
|
|
}
|
|
if !strings.HasPrefix(subJsonPath, "/") {
|
|
subJsonPath = "/" + subJsonPath
|
|
}
|
|
if !strings.HasSuffix(subJsonPath, "/") {
|
|
subJsonPath = subJsonPath + "/"
|
|
}
|
|
|
|
var subURL string
|
|
var subJsonURL string
|
|
|
|
// If pre-configured URIs are available, use them directly
|
|
if subURI != "" {
|
|
if !strings.HasSuffix(subURI, "/") {
|
|
subURI = subURI + "/"
|
|
}
|
|
subURL = fmt.Sprintf("%s%s", subURI, client.SubID)
|
|
} else {
|
|
subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
|
|
}
|
|
|
|
if subJsonURI != "" {
|
|
if !strings.HasSuffix(subJsonURI, "/") {
|
|
subJsonURI = subJsonURI + "/"
|
|
}
|
|
subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
|
|
} else {
|
|
subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
|
|
}
|
|
|
|
if !subJsonEnable {
|
|
subJsonURL = ""
|
|
}
|
|
return subURL, subJsonURL, nil
|
|
}
|
|
|
|
// clientSubLinks builds the subscription's links in-process for the host subURL
|
|
// names; fetching subURL instead fails whenever that host does not resolve here.
|
|
func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) {
|
|
u, err := url.Parse(subURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, client, err := t.inboundService.GetClientByEmail(email)
|
|
if err != nil || client == nil {
|
|
return nil, errors.New("client not found")
|
|
}
|
|
// A client stored without a subId has no subscription to look up; build
|
|
// its own links by email, as the panel's client page does.
|
|
if strings.TrimSpace(client.SubID) == "" {
|
|
return t.inboundService.GetAllClientLinks(u.Hostname(), email)
|
|
}
|
|
return t.inboundService.GetSubLinks(u.Hostname(), client.SubID)
|
|
}
|
|
|
|
// clientInfoMsg formats client information message based on traffic and flags.
|
|
func (t *Tgbot) clientInfoMsg(
|
|
traffic *xray.ClientTraffic,
|
|
printEnabled bool,
|
|
printOnline bool,
|
|
printActive bool,
|
|
printDate bool,
|
|
printTraffic bool,
|
|
printRefreshed bool,
|
|
) string {
|
|
now := time.Now().Unix()
|
|
expiryTime := ""
|
|
flag := false
|
|
diff := traffic.ExpiryTime/1000 - now
|
|
if traffic.ExpiryTime == 0 {
|
|
expiryTime = t.I18nBot("tgbot.unlimited")
|
|
} else if traffic.ExpiryTime < 0 {
|
|
// A negative expiry counts days from first use, not a date; the disabled
|
|
// branch below would otherwise render it as a 1969 timestamp.
|
|
expiryTime = fmt.Sprintf("%d %s", traffic.ExpiryTime/-86400000, t.I18nBot("tgbot.days"))
|
|
flag = true
|
|
} else if diff > 172800 || !traffic.Enable {
|
|
expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05")
|
|
if diff > 0 {
|
|
days := diff / 86400
|
|
hours := (diff % 86400) / 3600
|
|
minutes := (diff % 3600) / 60
|
|
remainingTime := ""
|
|
if days > 0 {
|
|
remainingTime += fmt.Sprintf("%d %s ", days, t.I18nBot("tgbot.days"))
|
|
}
|
|
if hours > 0 {
|
|
remainingTime += fmt.Sprintf("%d %s ", hours, t.I18nBot("tgbot.hours"))
|
|
}
|
|
if minutes > 0 {
|
|
remainingTime += fmt.Sprintf("%d %s", minutes, t.I18nBot("tgbot.minutes"))
|
|
}
|
|
expiryTime += fmt.Sprintf(" (%s)", remainingTime)
|
|
}
|
|
} else {
|
|
expiryTime = fmt.Sprintf("%d %s", diff/3600, t.I18nBot("tgbot.hours"))
|
|
flag = true
|
|
}
|
|
|
|
total := ""
|
|
if traffic.Total == 0 {
|
|
total = t.I18nBot("tgbot.unlimited")
|
|
} else {
|
|
total = common.FormatTraffic(traffic.Total)
|
|
}
|
|
|
|
enabled := ""
|
|
isEnabled, err := t.clientService.CheckIsEnabledByEmail(&t.inboundService, traffic.Email)
|
|
if err != nil {
|
|
logger.Warning(err)
|
|
enabled = t.I18nBot("tgbot.wentWrong")
|
|
} else if isEnabled {
|
|
enabled = t.I18nBot("tgbot.messages.yes")
|
|
} else {
|
|
enabled = t.I18nBot("tgbot.messages.no")
|
|
}
|
|
|
|
active := ""
|
|
if traffic.Enable {
|
|
active = t.I18nBot("tgbot.messages.yes")
|
|
} else {
|
|
active = t.I18nBot("tgbot.messages.no")
|
|
}
|
|
|
|
status := t.I18nBot("tgbot.offline")
|
|
isOnline := false
|
|
if process := service.XrayProcess(); process != nil && process.IsRunning() {
|
|
if slices.Contains(process.GetOnlineClients(), traffic.Email) {
|
|
status = t.I18nBot("tgbot.online")
|
|
isOnline = true
|
|
}
|
|
}
|
|
|
|
output := ""
|
|
output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email)
|
|
if attachIds, err := t.clientService.GetInboundIdsForEmail(nil, traffic.Email); err == nil && len(attachIds) > 0 {
|
|
output += fmt.Sprintf("🔗 Inbounds: %s\r\n", t.describeAttachedInbounds(attachIds))
|
|
}
|
|
if printEnabled {
|
|
output += t.I18nBot("tgbot.messages.enabled", "Enable=="+enabled)
|
|
}
|
|
if printOnline {
|
|
output += t.I18nBot("tgbot.messages.online", "Status=="+status)
|
|
if !isOnline && traffic.LastOnline > 0 {
|
|
output += t.I18nBot("tgbot.messages.lastOnline", "Time=="+time.UnixMilli(traffic.LastOnline).Format("2006-01-02 15:04:05"))
|
|
}
|
|
}
|
|
if printActive {
|
|
output += t.I18nBot("tgbot.messages.active", "Enable=="+active)
|
|
}
|
|
if printDate {
|
|
if flag {
|
|
output += t.I18nBot("tgbot.messages.expireIn", "Time=="+expiryTime)
|
|
} else {
|
|
output += t.I18nBot("tgbot.messages.expire", "Time=="+expiryTime)
|
|
}
|
|
}
|
|
if printTraffic {
|
|
output += t.I18nBot("tgbot.messages.upload", "Upload=="+common.FormatTraffic(traffic.Up))
|
|
output += t.I18nBot("tgbot.messages.download", "Download=="+common.FormatTraffic(traffic.Down))
|
|
output += t.I18nBot("tgbot.messages.total", "UpDown=="+common.FormatTraffic((traffic.Up+traffic.Down)), "Total=="+total)
|
|
}
|
|
if printRefreshed {
|
|
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
}
|
|
|
|
return output
|
|
}
|
|
|
|
// clientOwnedByTgUser reports whether email belongs to a client bound to this
|
|
// Telegram account, the same list the self-service usage command reads.
|
|
func (t *Tgbot) clientOwnedByTgUser(tgUserID int64, email string) bool {
|
|
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, traffic := range traffics {
|
|
if traffic.Email == email {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getClientUsage retrieves and sends client usage information to the chat.
|
|
func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
|
|
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
|
|
if err != nil {
|
|
logger.Warning(err)
|
|
msg := t.I18nBot("tgbot.wentWrong")
|
|
t.SendMsgToTgbot(chatId, msg)
|
|
return
|
|
}
|
|
|
|
if len(traffics) == 0 {
|
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
|
|
return
|
|
}
|
|
|
|
// A client sees only its own card, and never the admin keyboard: every button
|
|
// on it (reset traffic, limits, IP limit, enable/disable) acts on an account.
|
|
if t.levelOf(tgUserID) != levelAdmin {
|
|
t.screenClientHome(chatId, tgUserID)
|
|
return
|
|
}
|
|
|
|
output := ""
|
|
|
|
if len(traffics) > 0 {
|
|
if len(email) > 0 {
|
|
for _, traffic := range traffics {
|
|
if traffic.Email == email[0] {
|
|
output := t.clientInfoMsg(traffic, true, true, true, true, true, false)
|
|
t.SendMsgToTgbot(chatId, output)
|
|
return
|
|
}
|
|
}
|
|
msg := t.I18nBot("tgbot.noResult")
|
|
t.SendMsgToTgbot(chatId, msg)
|
|
return
|
|
} else {
|
|
for _, traffic := range traffics {
|
|
output += t.clientInfoMsg(traffic, true, true, true, true, true, false)
|
|
output += "\r\n"
|
|
}
|
|
}
|
|
}
|
|
|
|
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
rows := [][]telego.InlineKeyboardButton{}
|
|
if len(email) > 0 {
|
|
rows = t.clientRows(email[0])
|
|
} else if len(traffics) > 0 {
|
|
rows = t.clientRows(traffics[0].Email)
|
|
}
|
|
t.renderScreen(chatId, t.newScreen("client", output, rows...))
|
|
}
|
|
|
|
// getCommonClientButtons returns the shared inline keyboard rows for the
|
|
// client-first multi-inbound add flow. Per-protocol secrets (UUID, password,
|
|
// flow, method) are generated by fillProtocolDefaults on submit, so the bot
|
|
// only exposes the universal client fields here.
|
|
func (t *Tgbot) getCommonClientButtons(draft *clientDraft) [][]telego.InlineKeyboardButton {
|
|
attachLabel := t.I18nBot("tgbot.buttons.attachInbound",
|
|
"Count=="+strconv.Itoa(len(draft.receiverInboundIDs)))
|
|
return [][]telego.InlineKeyboardButton{
|
|
tu.InlineKeyboardRow(
|
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
|
),
|
|
tu.InlineKeyboardRow(
|
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
|
),
|
|
tu.InlineKeyboardRow(
|
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
|
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData("add_client_ch_default_tg_id"),
|
|
),
|
|
tu.InlineKeyboardRow(
|
|
tu.InlineKeyboardButton(attachLabel).WithCallbackData("add_client_attach_more"),
|
|
),
|
|
tu.InlineKeyboardRow(
|
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
|
),
|
|
tu.InlineKeyboardRow(
|
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
|
),
|
|
}
|
|
}
|
|
|
|
// addClient renders the draft as the chat's screen: the wizard keeps ONE
|
|
// message, and a value picker can edit it without losing the card.
|
|
func (t *Tgbot) addClient(chatId int64, draft *clientDraft, msg string, messageID ...int) {
|
|
if len(messageID) > 0 {
|
|
t.adoptScreen(chatId, messageID[0])
|
|
}
|
|
body := msg
|
|
if body == "" {
|
|
body = t.BuildClientDraftMessage(draft)
|
|
}
|
|
t.renderScreen(chatId, t.newScreen("wizard", body, t.getCommonClientButtons(draft)...))
|
|
}
|