mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-04 21:22:07 +03:00
bd01f923fb
* fix(tgbot): unstick the add-client wizard's inbound picker Tapping "➕ Новый клиент" always sent the "choose inbound" message, even when the button list ended up empty after protocol filtering. getInboundsAddClient checked len(inbounds)==0 before filtering but never re-checked after, so an admin whose every inbound was excluded got a message with nothing to tap and no further feedback. WireGuard and AmneziaWG were excluded outright too, a holdover from the wizard's original 2025 implementation, before defaultWireguardClients and defaultAmneziaWGClients existed. Both now auto-generate a keypair + AllowedIPs for a client with none set, and the subscription server already emits wireguard:// and vpn:// share links for them, so both inbound types flow through the same generic Create path as VLESS/Trojan already used by the bot. Mixed/HTTP/Tunnel stay excluded: they have no per-client model in this codebase. - getInboundsAddClient now returns getInboundsFailed when the button list is empty after filtering, instead of sending an unusable keyboard - WireGuard/AmneziaWG removed from the exclusion list in both getInboundsAddClient and getInboundsAttachPicker - the previously duplicated excludedProtocols map is now a single package-level addClientExcludedProtocols shared by both functions * test(tgbot): pin which inbounds the add-client picker offers The picker change had no test. One drives a database holding WireGuard, AmneziaWG, VLESS and Mixed inbounds and wants the first three offered; the other holds only Mixed, HTTP and Tunnel and wants getInboundsFailed instead of an empty keyboard. Both fail on the previous picker. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
300 lines
9.8 KiB
Go
300 lines
9.8 KiB
Go
package tgbot
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"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/mymmrac/telego"
|
|
tu "github.com/mymmrac/telego/telegoutil"
|
|
)
|
|
|
|
// getInboundUsages retrieves and formats inbound usage information.
|
|
func (t *Tgbot) getInboundUsages() string {
|
|
var info strings.Builder
|
|
inbounds, err := t.inboundService.GetAllInbounds()
|
|
if err != nil {
|
|
logger.Warning("GetAllInbounds run failed:", err)
|
|
info.WriteString(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
return info.String()
|
|
}
|
|
for _, inbound := range inbounds {
|
|
info.WriteString(t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark))
|
|
info.WriteString(t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)))
|
|
info.WriteString(t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)))
|
|
|
|
clients, listErr := t.clientService.ListForInbound(nil, inbound.Id)
|
|
if listErr == nil {
|
|
fmt.Fprintf(&info, "👥 Clients: %d\r\n", len(clients))
|
|
}
|
|
|
|
if inbound.ExpiryTime == 0 {
|
|
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")))
|
|
} else {
|
|
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")))
|
|
}
|
|
info.WriteString("\r\n")
|
|
}
|
|
return info.String()
|
|
}
|
|
|
|
// getInbounds creates an inline keyboard with all inbounds.
|
|
func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) {
|
|
inbounds, err := t.inboundService.GetAllInbounds()
|
|
if err != nil {
|
|
logger.Warning("GetAllInbounds run failed:", err)
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
}
|
|
|
|
if len(inbounds) == 0 {
|
|
logger.Warning("No inbounds found")
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
}
|
|
|
|
var buttons []telego.InlineKeyboardButton
|
|
for _, inbound := range inbounds {
|
|
status := "❌"
|
|
if inbound.Enable {
|
|
status = "✅"
|
|
}
|
|
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "get_clients", inbound.Id))
|
|
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
|
}
|
|
|
|
cols := 1
|
|
if len(buttons) >= 6 {
|
|
cols = 2
|
|
}
|
|
|
|
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
|
return keyboard, nil
|
|
}
|
|
|
|
// getInboundsFor builds an inline keyboard of inbounds for a custom next action.
|
|
func (t *Tgbot) getInboundsFor(nextAction string) (*telego.InlineKeyboardMarkup, error) {
|
|
inbounds, err := t.inboundService.GetAllInbounds()
|
|
if err != nil {
|
|
logger.Warning("GetAllInbounds run failed:", err)
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
}
|
|
|
|
if len(inbounds) == 0 {
|
|
logger.Warning("No inbounds found")
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
}
|
|
|
|
var buttons []telego.InlineKeyboardButton
|
|
for _, inbound := range inbounds {
|
|
status := "❌"
|
|
if inbound.Enable {
|
|
status = "✅"
|
|
}
|
|
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", nextAction, inbound.Id))
|
|
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
|
}
|
|
|
|
cols := 1
|
|
if len(buttons) >= 6 {
|
|
cols = 2
|
|
}
|
|
|
|
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
|
return keyboard, nil
|
|
}
|
|
|
|
// getInboundClientsFor lists clients of an inbound with a specific action prefix to be appended with email
|
|
func (t *Tgbot) getInboundClientsFor(inbound *model.Inbound, action string) (*telego.InlineKeyboardMarkup, error) {
|
|
clients, err := t.inboundService.GetClients(inbound)
|
|
var buttons []telego.InlineKeyboardButton
|
|
|
|
if err != nil {
|
|
logger.Warning("GetInboundClients run failed:", err)
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
} else {
|
|
if len(clients) > 0 {
|
|
for _, client := range clients {
|
|
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email)))
|
|
}
|
|
} else {
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
|
|
}
|
|
}
|
|
cols := 0
|
|
if len(buttons) < 6 {
|
|
cols = 3
|
|
} else {
|
|
cols = 2
|
|
}
|
|
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
|
|
|
return keyboard, nil
|
|
}
|
|
|
|
// addClientExcludedProtocols are the protocols with no per-client model: Tunnel
|
|
// has no clients, Mixed/HTTP authenticate at the inbound level, not per-client.
|
|
var addClientExcludedProtocols = map[model.Protocol]bool{
|
|
model.Tunnel: true,
|
|
model.Mixed: true,
|
|
model.HTTP: true,
|
|
}
|
|
|
|
// getInboundsAddClient creates an inline keyboard for adding clients to inbounds.
|
|
func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
|
|
inbounds, err := t.inboundService.GetAllInbounds()
|
|
if err != nil {
|
|
logger.Warning("GetAllInbounds run failed:", err)
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
}
|
|
|
|
if len(inbounds) == 0 {
|
|
logger.Warning("No inbounds found")
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
}
|
|
|
|
var buttons []telego.InlineKeyboardButton
|
|
for _, inbound := range inbounds {
|
|
if addClientExcludedProtocols[inbound.Protocol] {
|
|
continue
|
|
}
|
|
|
|
status := "❌"
|
|
if inbound.Enable {
|
|
status = "✅"
|
|
}
|
|
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "add_client_to", inbound.Id))
|
|
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
|
}
|
|
|
|
if len(buttons) == 0 {
|
|
logger.Warning("No inbounds eligible for add-client (all excluded by protocol)")
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
}
|
|
|
|
cols := 1
|
|
if len(buttons) >= 6 {
|
|
cols = 2
|
|
}
|
|
|
|
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
|
return keyboard, nil
|
|
}
|
|
|
|
// getInboundsAttachPicker builds a toggle picker over multi-client inbounds
|
|
// for the "attach more inbounds to the new client" step. Each row shows the
|
|
// current selection state for the inbound; tapping fires
|
|
// add_client_toggle_attach <id> which flips it and re-renders. A final
|
|
// "Done" button (add_client_attach_done) returns to the field-edit screen.
|
|
func (t *Tgbot) getInboundsAttachPicker(draft *clientDraft) (*telego.InlineKeyboardMarkup, error) {
|
|
inbounds, err := t.inboundService.GetAllInbounds()
|
|
if err != nil {
|
|
logger.Warning("GetAllInbounds run failed:", err)
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
}
|
|
if len(inbounds) == 0 {
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
}
|
|
selected := make(map[int]bool, len(draft.receiverInboundIDs))
|
|
for _, id := range draft.receiverInboundIDs {
|
|
selected[id] = true
|
|
}
|
|
var buttons []telego.InlineKeyboardButton
|
|
for _, ib := range inbounds {
|
|
if addClientExcludedProtocols[ib.Protocol] {
|
|
continue
|
|
}
|
|
mark := "☐"
|
|
if selected[ib.Id] {
|
|
mark = "✅"
|
|
}
|
|
label := fmt.Sprintf("%s %s (%s)", mark, ib.Remark, ib.Protocol)
|
|
callback := t.encodeQuery(fmt.Sprintf("add_client_toggle_attach %d", ib.Id))
|
|
buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(callback))
|
|
}
|
|
cols := 1
|
|
if len(buttons) >= 6 {
|
|
cols = 2
|
|
}
|
|
rows := tu.InlineKeyboardCols(cols, buttons...)
|
|
rows = append(rows, tu.InlineKeyboardRow(
|
|
tu.InlineKeyboardButton("✅ Done").WithCallbackData(t.encodeQuery("add_client_attach_done")),
|
|
))
|
|
return tu.InlineKeyboardGrid(rows), nil
|
|
}
|
|
|
|
// getInboundClients creates an inline keyboard with clients of a specific inbound.
|
|
func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error) {
|
|
inbound, err := t.inboundService.GetInbound(id)
|
|
if err != nil {
|
|
logger.Warning("getIboundClients run failed:", err)
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
}
|
|
clients, err := t.inboundService.GetClients(inbound)
|
|
var buttons []telego.InlineKeyboardButton
|
|
|
|
if err != nil {
|
|
logger.Warning("GetInboundClients run failed:", err)
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
} else {
|
|
if len(clients) > 0 {
|
|
for _, client := range clients {
|
|
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery("client_get_usage "+client.Email)))
|
|
}
|
|
} else {
|
|
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
|
|
}
|
|
}
|
|
cols := 0
|
|
if len(buttons) < 6 {
|
|
cols = 3
|
|
} else {
|
|
cols = 2
|
|
}
|
|
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
|
|
|
return keyboard, nil
|
|
}
|
|
|
|
// searchInbound searches for inbounds by remark and sends the results.
|
|
func (t *Tgbot) searchInbound(chatId int64, remark string) {
|
|
inbounds, err := t.inboundService.SearchInbounds(remark)
|
|
if err != nil {
|
|
logger.Warning(err)
|
|
msg := t.I18nBot("tgbot.wentWrong")
|
|
t.SendMsgToTgbot(chatId, msg)
|
|
return
|
|
}
|
|
if len(inbounds) == 0 {
|
|
msg := t.I18nBot("tgbot.noInbounds")
|
|
t.SendMsgToTgbot(chatId, msg)
|
|
return
|
|
}
|
|
|
|
for _, inbound := range inbounds {
|
|
info := ""
|
|
info += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
|
|
info += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
|
|
info += 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 {
|
|
info += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
|
|
} else {
|
|
info += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
|
|
}
|
|
t.SendMsgToTgbot(chatId, info)
|
|
|
|
if len(inbound.ClientStats) > 0 {
|
|
var output strings.Builder
|
|
for _, traffic := range inbound.ClientStats {
|
|
output.WriteString(t.clientInfoMsg(&traffic, true, true, true, true, true, true))
|
|
}
|
|
t.SendMsgToTgbot(chatId, output.String())
|
|
}
|
|
}
|
|
}
|