Files
3x-ui/internal/web/service/tgbot/tgbot_router.go
T
MHSanaei 99f386820d fix(tgbot): treat messages sent on behalf of a chat as strangers
Telegram fills a message's From with one of three fixed placeholder
users whenever it is posted on behalf of a chat, by an anonymous group
admin or a channel. The level gate and the invite claim took that
placeholder for the account acting, so it got a throttle window of its
own, and a claim bound the client to an id every anonymous admin shares.

Such a message is rated as a stranger whatever the placeholder id is
bound to, and its invite claim is refused before it is counted or bound.
2026-11-01 13:47:40 +01:00

1306 lines
52 KiB
Go

package tgbot
import (
"context"
"slices"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mymmrac/telego"
th "github.com/mymmrac/telego/telegohandler"
tu "github.com/mymmrac/telego/telegoutil"
)
// recoverBotPanic must be deferred by every bot handler entry point: telego's
// dispatch has no recovery of its own, so one bad update would kill the panel.
func recoverBotPanic() {
if r := recover(); r != nil {
logger.Error("Recovered panic in Telegram bot handler:", r)
}
}
// runBotHandler runs a bot handler on a worker slot and recovers panics: a bad
// callback must not take down the whole panel, the way a cron panic would not.
func runBotHandler(fn func()) {
messageWorkerPool <- struct{}{}
defer func() { <-messageWorkerPool }()
defer recoverBotPanic()
fn()
}
// chooseInboundClient fetches the inbound once and reuses the row: the inline
// keyboard outlives it, so a stale tap must give an error, not a panic.
func (t *Tgbot) chooseInboundClient(callbackQuery *telego.CallbackQuery, chatId int64, inboundID int, action string) {
inbound, err := t.inboundService.GetInbound(inboundID)
if err != nil {
logger.Warning("chooseInboundClient GetInbound failed:", err)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getInboundsFailed"))
return
}
clients, err := t.inboundService.GetClients(inbound)
if err != nil || len(clients) == 0 {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getClientsFailed"))
return
}
rows := [][]telego.InlineKeyboardButton{}
for _, client := range clients {
rows = append(rows, tu.InlineKeyboardRow(
tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email))))
}
rows = append(rows, t.backRow())
t.answerSilent(callbackQuery.ID)
t.renderScreen(chatId, t.newScreen("clients",
t.inboundSummaryText(inbound)+"\n"+t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), rows...))
}
// OnReceive starts the message receiving loop for the Telegram liveBot().
func (t *Tgbot) OnReceive() {
params := telego.GetUpdatesParams{
Timeout: 20, // Reduced timeout to detect connection issues faster
}
// Strict singleton: never start a second long-polling loop.
tgBotMutex.Lock()
if botCancel != nil || isRunning {
tgBotMutex.Unlock()
logger.Warning("TgBot OnReceive called while already running; ignoring.")
return
}
ctx, cancel := context.WithCancel(context.Background())
botCancel = cancel
isRunning = true
// Add to WaitGroup before releasing the lock so StopBot() can't return
// before this receiver goroutine is accounted for.
botWG.Add(1)
tgBotMutex.Unlock()
// Get updates channel using the context with shorter timeout for better error recovery
// One bot instance for the loop and the handler it feeds: reading the global twice could diverge them.
pollBot := liveBot()
updates, _ := pollBot.UpdatesViaLongPolling(ctx, &params)
go func() {
defer botWG.Done()
h, _ := th.NewBotHandler(pollBot, updates)
tgBotMutex.Lock()
botHandler = h
tgBotMutex.Unlock()
// The menu marks these as returning to a root screen, so a screen that
// carries one already offers a way out.
registerTerminalCallbacks(cbHome, cbCatServer, cbCatClients, cbCatTraffic, cbCatMaintenance)
refuseGroups(h, t)
// A picked inline item is the user's own message, caught before the wizard
// and broadcast handlers; the predicate must not match every ":" message.
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
defer recoverBotPanic()
t.handleListMarker(&message)
return nil
}, messageIsListMarker, th.AnyMessageWithFrom())
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
defer recoverBotPanic()
userStateMgr.clear(messageActor(message))
t.clearScreen(message)
return nil
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
defer recoverBotPanic()
if !t.isCommandForCurrentBot(&message) {
return nil
}
// Use goroutine with worker pool for concurrent command processing
go runBotHandler(func() {
userStateMgr.clear(messageActor(message))
if isAdmin, ok := t.gateCommand(&message); ok {
t.answerCommand(&message, message.Chat.ID, isAdmin)
}
// The command is litter next to the screen it opens: the bot's answer
// is worth keeping. Commands for another bot never reach here.
t.deleteIncoming(&message)
})
return nil
}, th.AnyCommand())
// Inline mode is the only list browser: no middleware is registered on it,
// since a query fires on every keystroke and would be swallowed.
h.HandleInlineQuery(func(ctx *th.Context, query telego.InlineQuery) error {
defer recoverBotPanic()
go runBotHandler(func() {
t.handleInlineQuery(&query)
})
return nil
})
h.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error {
// Use goroutine with worker pool for concurrent callback processing
go runBotHandler(func() {
userStateMgr.clear(callbackActor(&query))
if isAdmin, ok := t.gateCallback(&query); ok {
t.answerCallback(&query, isAdmin)
}
})
return nil
}, th.AnyCallbackQueryWithMessage())
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
defer recoverBotPanic()
userStateMgr.maybePrune(time.Hour)
actor := messageActor(message)
if userState, exists := userStateMgr.get(actor); exists {
if userState == broadcastAwaitingText {
t.handleBroadcastInput(&message, actor)
return nil
}
// Only a wizard step touches the draft, so only it takes the lock.
draft := addClientDrafts.forActor(actor)
draft.Lock()
defer draft.Unlock()
switch userState {
case "awaiting_email":
if draft.email == strings.TrimSpace(message.Text) {
t.deleteIncoming(&message)
userStateMgr.clear(actor)
return nil
}
draft.email = strings.TrimSpace(message.Text)
if t.isSingleWord(draft.email) {
userStateMgr.set(actor, "awaiting_email")
t.deleteIncoming(&message)
t.wizardInvalidInput(message.Chat.ID, draft)
} else {
t.deleteIncoming(&message)
userStateMgr.clear(actor)
t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft))
}
case "awaiting_comment":
if draft.comment == strings.TrimSpace(message.Text) {
t.deleteIncoming(&message)
userStateMgr.clear(actor)
return nil
}
draft.comment = strings.TrimSpace(message.Text)
t.deleteIncoming(&message)
userStateMgr.clear(actor)
t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft))
case "awaiting_tg_id":
input := strings.TrimSpace(message.Text)
if input == "" || input == "-" || strings.EqualFold(input, "none") {
draft.tgID = ""
t.deleteIncoming(&message)
userStateMgr.clear(actor)
t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft))
return nil
}
if _, err := strconv.ParseInt(input, 10, 64); err != nil {
t.deleteIncoming(&message)
t.wizardInvalidInput(message.Chat.ID, draft)
return nil
}
draft.tgID = input
t.deleteIncoming(&message)
userStateMgr.clear(actor)
t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft))
}
} else {
if message.UsersShared != nil {
if checkAdmin(message.From.ID) {
for _, sharedUser := range message.UsersShared.Users {
userID := sharedUser.UserID
needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, message.UsersShared.RequestID, userID)
if needRestart {
t.xrayService.SetToNeedRestart()
}
output := t.I18nBot("tgbot.messages.userSaved")
if err != nil {
output = t.I18nBot("tgbot.messages.selectUserFailed")
}
t.sendNoticeNoKeyboard(message.Chat.ID, output)
}
} else {
t.sendNoticeNoKeyboard(message.Chat.ID, t.I18nBot("tgbot.noResult"))
}
}
}
return nil
}, th.AnyMessage())
_ = h.Start()
}()
}
// answerCommand processes incoming command messages from Telegram users.
func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin bool) {
// onlyMessage is deliberately unused: every command path either renders a
// screen or sends a notice, both of which carry their own keyboard.
msg := ""
command, _, commandArgs := tu.ParseCommand(message.Text)
// Helper function to handle unknown commands.
handleUnknownCommand := func() {
msg += t.I18nBot("tgbot.commands.unknown")
}
// Handle the command.
switch command {
case "help":
msg += t.I18nBot("tgbot.commands.help")
msg += t.I18nBot("tgbot.commands.pleaseChoose")
case "start":
if len(commandArgs) > 0 {
// A placeholder user can be neither throttled nor bound, so its claim is
// refused before it counts: the link works from the account's own chat.
if sentForChat(message) {
t.sendNotice(chatId, t.I18nBot("tgbot.messages.inviteInvalid"))
return
}
if !isAdmin && !t.allowInviteAttempt(message.From) {
t.sendNotice(chatId, t.I18nBot("tgbot.messages.inviteRateLimited"))
return
}
t.claimInvite(chatId, message.From.ID, commandArgs[0])
}
// A stranger learns only its ChatID, which is what an admin needs to bind it.
if !isAdmin && (sentForChat(message) || t.levelOf(message.From.ID) == levelStranger) {
t.sendNotice(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(message.From.ID, 10)))
return
}
t.clearBroadcastPrompt(messageActor(*message))
t.screenHome(chatId, message.From.ID)
return
case "status":
// The full server screen carries the host addresses and versions, so it
// stays admin-only; a client asking for status gets liveness alone.
if isAdmin {
t.screenServer(chatId)
return
}
t.sendNotice(chatId, t.I18nBot("tgbot.commands.statusOk"))
return
case "id":
t.sendNotice(chatId, t.I18nBot("tgbot.commands.getID", "ID=="+strconv.FormatInt(message.From.ID, 10)))
return
case "usage":
if len(commandArgs) > 0 {
if isAdmin {
t.searchClientScreen(chatId, commandArgs[0])
} else {
t.getClientUsage(chatId, message.From.ID, commandArgs[0])
}
} else {
t.getClientUsage(chatId, message.From.ID)
}
return
case "inbound":
if isAdmin && len(commandArgs) > 0 {
t.searchInbound(chatId, commandArgs[0])
} else {
handleUnknownCommand()
}
case "restart":
if isAdmin {
if len(commandArgs) == 0 {
if t.xrayService.IsXrayRunning() {
err := t.xrayService.RestartXray(true)
if err != nil {
msg += t.I18nBot("tgbot.commands.restartFailed", "Error=="+err.Error())
} else {
msg += t.I18nBot("tgbot.commands.restartSuccess")
}
} else {
msg += t.I18nBot("tgbot.commands.xrayNotRunning")
}
} else {
handleUnknownCommand()
msg += t.I18nBot("tgbot.commands.restartUsage")
}
} else {
handleUnknownCommand()
}
case "clearall":
if isAdmin {
t.renderScreen(chatId, t.resetAllConfirm())
return
}
handleUnknownCommand()
case "broadcast":
if isAdmin {
t.startBroadcast(messageActor(*message))
return
}
handleUnknownCommand()
default:
handleUnknownCommand()
}
if msg != "" {
t.sendNotice(chatId, msg)
}
}
func (t *Tgbot) isCommandForCurrentBot(message *telego.Message) bool {
return isCommandForBot(message.Text, botUsername())
}
func botUsername() string {
if liveBot() == nil {
return ""
}
return liveBot().Username()
}
func isCommandForBot(text string, username string) bool {
_, commandUsername, _ := tu.ParseCommand(text)
return commandUsername == "" || username == "" || strings.EqualFold(commandUsername, username)
}
// answerCallback processes callback queries from inline keyboards.
func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool) {
chatId := callbackQuery.Message.GetChat().ID
actor := callbackActor(callbackQuery)
// Only an admin's wizard callbacks touch a draft, so only they take its lock:
// a report tap must not wait on a slot, a rejected chat must not be stored.
var draft *clientDraft
if isAdmin && isAddClientStep(callbackQuery.Data) {
draft = addClientDrafts.forActor(actor)
draft.Lock()
defer draft.Unlock()
}
if isAdmin {
// get query from hash storage
decodedQuery, err := t.decodeQuery(callbackQuery.Data)
if err != nil {
// A button older than the 20-minute hash window is the common case
// here; the answer clears it, the message outlives a failed send.
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.noQuery"))
t.sendNotice(chatId, t.I18nBot("tgbot.noQuery"))
return
}
dataArray := strings.Split(decodedQuery, " ")
if len(dataArray) >= 2 && len(dataArray[1]) > 0 {
email := dataArray[1]
switch dataArray[0] {
case "broadcast_confirm":
t.confirmBroadcast(actor, dataArray[1], callbackQuery.Message.GetMessageID(), callbackQuery.ID)
case "get_clients_for_sub":
inboundIdInt, err := strconv.Atoi(dataArray[1])
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.chooseInboundClient(callbackQuery, chatId, inboundIdInt, "client_sub_links")
case "get_clients_for_individual":
inboundIdInt, err := strconv.Atoi(dataArray[1])
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.chooseInboundClient(callbackQuery, chatId, inboundIdInt, "client_individual_links")
case "get_clients_for_qr":
inboundIdInt, err := strconv.Atoi(dataArray[1])
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.chooseInboundClient(callbackQuery, chatId, inboundIdInt, "client_qr_links")
case "client_sub_links":
t.screenClientLinks(chatId, email)
return
case "client_individual_links":
t.screenIndividualLinks(chatId, email)
return
case "client_qr_links":
t.screenClientQR(chatId, email)
return
case "client_get_usage":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.messages.email", "Email=="+email))
t.searchClientScreen(chatId, email)
case "client_refresh":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.clientRefreshSuccess", "Email=="+email))
t.searchClientScreen(chatId, email)
case "client_cancel":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email))
t.searchClientScreen(chatId, email)
case "ips_refresh":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.IpRefreshSuccess", "Email=="+email))
t.screenClientIps(chatId, email)
case "ips_cancel":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email))
t.screenClientIps(chatId, email)
case "tgid_refresh":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.TGIdRefreshSuccess", "Email=="+email))
t.screenClientTG(chatId, email, false)
case "tgid_cancel":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email))
t.screenClientTG(chatId, email, false)
case "reset_traffic":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelReset")).WithCallbackData(t.encodeQuery("client_cancel "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmResetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic_c "+email)),
),
)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
case "reset_traffic_c":
err := t.inboundService.ResetClientTrafficByEmail(email)
if err == nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.resetTrafficSuccess", "Email=="+email))
t.searchClientScreen(chatId, email)
} else {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
}
case "limit_traffic":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 0")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" 0")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("1 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 1")),
tu.InlineKeyboardButton("5 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 5")),
tu.InlineKeyboardButton("10 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 10")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("20 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 20")),
tu.InlineKeyboardButton("30 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 30")),
tu.InlineKeyboardButton("40 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 40")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("50 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 50")),
tu.InlineKeyboardButton("60 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 60")),
tu.InlineKeyboardButton("80 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 80")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("100 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 100")),
tu.InlineKeyboardButton("150 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 150")),
tu.InlineKeyboardButton("200 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 200")),
),
)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
case "limit_traffic_c":
if len(dataArray) == 3 {
limitTraffic, err := strconv.Atoi(dataArray[2])
if err == nil {
needRestart, err := t.clientService.ResetClientTrafficLimitByEmail(&t.inboundService, email, limitTraffic)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.setTrafficLimitSuccess", "Email=="+email))
t.searchClientScreen(chatId, email)
return
}
}
}
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
t.searchClientScreen(chatId, email)
case "limit_traffic_in":
if len(dataArray) >= 3 {
oldInputNumber, err := strconv.Atoi(dataArray[2])
inputNumber := oldInputNumber
if err == nil {
if len(dataArray) == 4 {
num, err := strconv.Atoi(dataArray[3])
if err == nil {
inputNumber = updateNumericInput(inputNumber, num)
}
if inputNumber == oldInputNumber {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
return
}
if inputNumber >= 999999 {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
}
inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "limit_traffic", dataArgs: email + " ", cancelData: "client_cancel " + email, confirmLabelKey: "tgbot.buttons.confirmNumberAdd"}, inputNumber)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
return
}
}
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
t.searchClientScreen(chatId, email)
case "add_client_limit_traffic_c":
limitTraffic, _ := strconv.ParseInt(dataArray[1], 10, 64)
draft.totalGB = limitTraffic * 1024 * 1024 * 1024
messageId := callbackQuery.Message.GetMessageID()
message_text := t.BuildClientDraftMessage(draft)
t.addClient(callbackQuery.Message.GetChat().ID, draft, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
case "add_client_limit_traffic_in":
if len(dataArray) >= 2 {
oldInputNumber, err := strconv.Atoi(dataArray[1])
inputNumber := oldInputNumber
if err == nil {
if len(dataArray) == 3 {
num, err := strconv.Atoi(dataArray[2])
if err == nil {
inputNumber = updateNumericInput(inputNumber, num)
}
if inputNumber == oldInputNumber {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
return
}
if inputNumber >= 999999 {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
}
inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "add_client_limit_traffic", cancelData: "add_client_default_traffic_exp", confirmLabelKey: "tgbot.buttons.confirmNumberAdd"}, inputNumber)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
return
}
}
case "reset_exp":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelReset")).WithCallbackData(t.encodeQuery("client_cancel "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 0")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("reset_exp_in "+email+" 0")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 7 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 7")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 10 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 10")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 14 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 14")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 20 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 20")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 1 "+t.I18nBot("tgbot.month")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 30")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 3 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 90")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 6 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 180")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 12 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 365")),
),
)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
case "reset_exp_c":
if len(dataArray) == 3 {
days, err := strconv.ParseInt(dataArray[2], 10, 64)
if err == nil {
var date int64
if days > 0 {
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
if err != nil {
logger.Warning(err)
t.sendNotice(chatId, t.I18nBot("tgbot.wentWrong"))
return
}
if traffic == nil {
t.sendNotice(chatId, t.I18nBot("tgbot.noResult"))
return
}
if traffic.ExpiryTime > 0 {
if traffic.ExpiryTime-time.Now().Unix()*1000 < 0 {
date = -(days * 24 * 60 * 60000)
} else {
date = traffic.ExpiryTime + days*24*60*60000
}
} else {
date = traffic.ExpiryTime - days*24*60*60000
}
}
needRestart, err := t.clientService.ResetClientExpiryTimeByEmail(&t.inboundService, email, date)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.expireResetSuccess", "Email=="+email))
t.searchClientScreen(chatId, email)
return
}
}
}
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
t.searchClientScreen(chatId, email)
case "reset_exp_in":
if len(dataArray) >= 3 {
oldInputNumber, err := strconv.Atoi(dataArray[2])
inputNumber := oldInputNumber
if err == nil {
if len(dataArray) == 4 {
num, err := strconv.Atoi(dataArray[3])
if err == nil {
inputNumber = updateNumericInput(inputNumber, num)
}
if inputNumber == oldInputNumber {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
return
}
if inputNumber >= 999999 {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
}
inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "reset_exp", dataArgs: email + " ", cancelData: "client_cancel " + email, confirmLabelKey: "tgbot.buttons.confirmNumber"}, inputNumber)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
return
}
}
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
t.searchClientScreen(chatId, email)
case "add_client_reset_exp_c":
// The wizard's presets and its custom keypad land in this one case, so a
// second tap replaces the term it set; 0 is the Unlimited button.
days, _ := strconv.ParseInt(dataArray[1], 10, 64)
draft.expiryTime = -days * 24 * 60 * 60000
messageId := callbackQuery.Message.GetMessageID()
message_text := t.BuildClientDraftMessage(draft)
t.addClient(callbackQuery.Message.GetChat().ID, draft, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
case "add_client_reset_exp_in":
if len(dataArray) >= 2 {
oldInputNumber, err := strconv.Atoi(dataArray[1])
inputNumber := oldInputNumber
if err == nil {
if len(dataArray) == 3 {
num, err := strconv.Atoi(dataArray[2])
if err == nil {
inputNumber = updateNumericInput(inputNumber, num)
}
if inputNumber == oldInputNumber {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
return
}
if inputNumber >= 999999 {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
}
inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "add_client_reset_exp", cancelData: "add_client_default_traffic_exp", confirmLabelKey: "tgbot.buttons.confirmNumberAdd"}, inputNumber)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
return
}
}
case "ip_limit":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelIpLimit")).WithCallbackData(t.encodeQuery("client_cancel "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 0")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("ip_limit_in "+email+" 0")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 1")),
tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 2")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 3")),
tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 4")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 5")),
tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 6")),
tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 7")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 8")),
tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 9")),
tu.InlineKeyboardButton("10").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 10")),
),
)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
case "ip_limit_c":
if len(dataArray) == 3 {
count, err := strconv.Atoi(dataArray[2])
if err == nil {
needRestart, err := t.clientService.ResetClientIpLimitByEmail(&t.inboundService, email, count)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.resetIpSuccess", "Email=="+email, "Count=="+strconv.Itoa(count)))
t.searchClientScreen(chatId, email)
return
}
}
}
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
t.searchClientScreen(chatId, email)
case "ip_limit_in":
if len(dataArray) >= 3 {
oldInputNumber, err := strconv.Atoi(dataArray[2])
inputNumber := oldInputNumber
if err == nil {
if len(dataArray) == 4 {
num, err := strconv.Atoi(dataArray[3])
if err == nil {
inputNumber = updateNumericInput(inputNumber, num)
}
if inputNumber == oldInputNumber {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
return
}
if inputNumber >= 999999 {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
}
inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "ip_limit", dataArgs: email + " ", cancelData: "client_cancel " + email, confirmLabelKey: "tgbot.buttons.confirmNumber"}, inputNumber)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
return
}
}
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
t.searchClientScreen(chatId, email)
case "add_client_ip_limit_c":
if len(dataArray) == 2 {
count, _ := strconv.Atoi(dataArray[1])
draft.limitIP = count
}
messageId := callbackQuery.Message.GetMessageID()
message_text := t.BuildClientDraftMessage(draft)
t.addClient(callbackQuery.Message.GetChat().ID, draft, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
case "add_client_ip_limit_in":
if len(dataArray) >= 2 {
oldInputNumber, err := strconv.Atoi(dataArray[1])
inputNumber := oldInputNumber
if err == nil {
if len(dataArray) == 3 {
num, err := strconv.Atoi(dataArray[2])
if err == nil {
inputNumber = updateNumericInput(inputNumber, num)
}
if inputNumber == oldInputNumber {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
return
}
if inputNumber >= 999999 {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
}
inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "add_client_ip_limit", cancelData: "add_client_default_ip_limit", confirmLabelKey: "tgbot.buttons.confirmNumber"}, inputNumber)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
return
}
}
case "clear_ips":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("ips_cancel "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmClearIps")).WithCallbackData(t.encodeQuery("clear_ips_c "+email)),
),
)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
case "clear_ips_c":
err := t.inboundService.ClearClientIps(email)
if err == nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.clearIpSuccess", "Email=="+email))
t.screenClientIps(chatId, email)
} else {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
t.screenClientIps(chatId, email)
}
case "ip_log":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getIpLog", "Email=="+email))
t.screenClientIps(chatId, email)
case "tgid_pick":
userStateMgr.set(actor, "awaiting_tg_pick")
t.answerSilent(callbackQuery.ID)
t.screenClientTG(chatId, email, false)
t.sendTGPicker(chatId, t.clientTrafficID(email))
case "tg_user":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getUserInfo", "Email=="+email))
t.screenClientTG(chatId, email, false)
case "client_invite_link":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.inviteLink"))
t.screenInviteLink(chatId, email)
case "tgid_remove":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("tgid_cancel "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmRemoveTGUser")).WithCallbackData(t.encodeQuery("tgid_remove_c "+email)),
),
)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
case "tgid_remove_c":
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
if err != nil || traffic == nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, traffic.Id, EmptyTelegramUserID)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.removedTGUserSuccess", "Email=="+email))
t.screenClientTG(chatId, email, false)
} else {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
t.screenClientTG(chatId, email, false)
}
case "toggle_enable":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmToggle")).WithCallbackData(t.encodeQuery("toggle_enable_c "+email)),
),
)
t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard)
case "toggle_enable_c":
enabled, needRestart, err := t.clientService.ToggleClientEnableByEmail(&t.inboundService, email)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
if enabled {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.enableSuccess", "Email=="+email))
} else {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.disableSuccess", "Email=="+email))
}
t.searchClientScreen(chatId, email)
} else {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
t.searchClientScreen(chatId, email)
}
case "get_clients":
inboundIdInt, err := strconv.Atoi(dataArray[1])
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.answerSilent(callbackQuery.ID)
t.screenInboundClients(chatId, inboundIdInt)
case "add_client_to":
inboundIdInt, err := strconv.Atoi(dataArray[1])
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.resetDraft(draft)
draft.receiverInboundID = inboundIdInt
draft.receiverInboundIDs = []int{inboundIdInt}
t.addClient(callbackQuery.Message.GetChat().ID, draft, t.BuildClientDraftMessage(draft))
case "add_client_toggle_attach":
inboundIdStr := dataArray[1]
inboundIdInt, err := strconv.Atoi(inboundIdStr)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
found := -1
for i, id := range draft.receiverInboundIDs {
if id == inboundIdInt {
found = i
break
}
}
if found >= 0 {
draft.receiverInboundIDs = append(draft.receiverInboundIDs[:found], draft.receiverInboundIDs[found+1:]...)
} else {
draft.receiverInboundIDs = append(draft.receiverInboundIDs, inboundIdInt)
}
picker, err := t.getInboundsAttachPicker(draft)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.renderDraftPicker(callbackQuery.Message.GetChat().ID, draft, picker.InlineKeyboard)
default:
// An unknown action with arguments is still a tap, and an
// unanswered tap spins until Telegram times it out.
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
}
return
} else {
switch callbackQuery.Data {
case "broadcast_cancel":
t.cancelBroadcast(actor, callbackQuery.Message.GetMessageID(), callbackQuery.ID)
// The broadcast step owns its own message; nothing else runs.
return
case "get_inbounds":
t.screenInbounds(chatId, callbackQuery.From.ID)
case "admin_client_sub_links", "admin_client_individual_links", "admin_client_qr_links":
t.browseInboundsScreen(chatId, callbackQuery.From.ID)
}
}
}
if !isAdmin {
// encodeQuery hashes any payload past 64 chars, so a long email's button
// must be decoded before the gate can see which client it names.
if decoded, err := t.decodeQuery(callbackQuery.Data); err == nil {
callbackQuery.Data = decoded
}
if !isClientSelfCallback(callbackQuery.Data) {
return
}
}
switch callbackQuery.Data {
case "home":
t.screenHome(chatId, callbackQuery.From.ID)
case cbCatServer, cbCatClients, cbCatTraffic, cbCatMaintenance:
t.screenCategory(chatId, strings.TrimPrefix(callbackQuery.Data, "cat:"))
case "act":
// The old "Actions" screen folded into the Maintenance category.
t.screenCategory(chatId, "maintenance")
case "hide":
t.hideMessage(callbackQuery)
case "pg:next":
t.turnPage(callbackQuery, 1)
case "pg:prev":
t.turnPage(callbackQuery, -1)
case "pg:none":
t.answerSilent(callbackQuery.ID)
case "srv":
t.answerSilent(callbackQuery.ID)
t.screenServer(chatId)
case "inb", "inbounds", "get_inbounds":
t.screenInbounds(chatId, callbackQuery.From.ID)
case "cli":
t.answerSilent(callbackQuery.ID)
t.screenAllClients(chatId)
case "onl", "onlines", "onlines_refresh":
t.answerSilent(callbackQuery.ID)
t.screenOnlines(chatId)
case "dep", "deplete_soon":
t.screenDeplete(chatId)
case "rep", "get_sorted_traffic_usage_report":
t.screenReport(chatId)
case "bkp", "get_backup":
t.answerSilent(callbackQuery.ID)
t.sendBackupScreen(chatId)
case "ban", "get_banlogs":
t.answerSilent(callbackQuery.ID)
t.screenBanLogs(chatId)
case "inline_help":
t.answerSilent(callbackQuery.ID)
t.screenInlineHelp(chatId)
case "inline_recheck":
t.refreshInlineCapability()
if t.inlineSupported() {
t.screenInbounds(chatId, callbackQuery.From.ID)
} else {
t.screenInlineHelp(chatId)
}
case "cmd", "commands", "client_commands":
t.screenCommands(chatId, isAdmin)
case "addc", "add_client":
t.screenAddClientStart(chatId, callbackQuery.From.ID)
case cbBroadcast:
// The broadcast flow already existed behind /broadcast; the button is
// the discoverable way in. It owns its own messages, so no screen here.
t.answerSilent(callbackQuery.ID)
t.startBroadcast(actor)
case "xray_restart":
t.restartXrayFromScreen(callbackQuery)
case "rst_all", "reset_all_traffics":
t.renderScreen(chatId, t.resetAllConfirm())
case "client_traffic":
t.answerSilent(callbackQuery.ID)
t.getClientUsage(chatId, callbackQuery.From.ID)
case "client_sub_links", "client_individual_links", "client_qr_links":
// A client tapped its own link button: its identity already says which
// subscription, so it opens directly. A second one still needs a choice.
t.openOwnClientScreen(chatId, callbackQuery.From.ID, callbackQuery.Data)
case "add_client_ch_default_email":
userStateMgr.set(actor, "awaiting_email")
t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+draft.email))
case "add_client_ch_default_comment":
userStateMgr.set(actor, "awaiting_comment")
t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+draft.comment))
case "add_client_ch_default_tg_id":
userStateMgr.set(actor, "awaiting_tg_id")
current := draft.tgID
if current == "" {
current = "—"
}
t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.tgid_prompt", "ClientTGID=="+current))
case "add_client_ch_default_traffic":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 0")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("add_client_limit_traffic_in 0")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("1 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 1")),
tu.InlineKeyboardButton("5 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 5")),
tu.InlineKeyboardButton("10 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 10")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("20 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 20")),
tu.InlineKeyboardButton("30 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 30")),
tu.InlineKeyboardButton("40 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 40")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("50 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 50")),
tu.InlineKeyboardButton("60 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 60")),
tu.InlineKeyboardButton("80 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 80")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("100 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 100")),
tu.InlineKeyboardButton("150 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 150")),
tu.InlineKeyboardButton("200 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 200")),
),
)
t.renderDraftPicker(chatId, draft, inlineKeyboard.InlineKeyboard)
case "add_client_ch_default_exp":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 0")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("add_client_reset_exp_in 0")),
),
// No "Add" verb: these replace the term the draft carries, unlike the
// renewal keyboard, whose reset_exp_c handler really does add to it.
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("7 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 7")),
tu.InlineKeyboardButton("10 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 10")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("14 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 14")),
tu.InlineKeyboardButton("20 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 20")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("1 "+t.I18nBot("tgbot.month")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 30")),
tu.InlineKeyboardButton("3 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 90")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("6 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 180")),
tu.InlineKeyboardButton("12 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 365")),
),
)
t.renderDraftPicker(chatId, draft, inlineKeyboard.InlineKeyboard)
case "add_client_ch_default_ip_limit":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_ip_limit")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("add_client_ip_limit_c 0")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("add_client_ip_limit_in 0")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 1")),
tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 2")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 3")),
tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 4")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 5")),
tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 6")),
tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 7")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 8")),
tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 9")),
tu.InlineKeyboardButton("10").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 10")),
),
)
t.renderDraftPicker(chatId, draft, inlineKeyboard.InlineKeyboard)
case "add_client_default_info":
userStateMgr.clear(actor)
t.addClient(chatId, draft, t.BuildClientDraftMessage(draft), callbackQuery.Message.GetMessageID())
case "add_client_cancel":
userStateMgr.clear(actor)
addClientDrafts.reset(actor)
t.renderScreen(chatId, t.newScreen("main", t.I18nBot("tgbot.messages.cancel"), t.homeRows()...))
case "add_client_default_traffic_exp", "add_client_default_ip_limit":
// The picker was cancelled: the draft comes back unchanged.
t.addClient(chatId, draft, t.BuildClientDraftMessage(draft), callbackQuery.Message.GetMessageID())
case "add_client_attach_more":
picker, err := t.getInboundsAttachPicker(draft)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.renderDraftPicker(chatId, draft, picker.InlineKeyboard)
case "add_client_attach_done":
if draft.receiverInboundID == 0 && len(draft.receiverInboundIDs) > 0 {
draft.receiverInboundID = draft.receiverInboundIDs[0]
}
if draft.receiverInboundID == 0 {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getInboundsFailed"))
return
}
t.addClient(chatId, draft, t.BuildClientDraftMessage(draft), callbackQuery.Message.GetMessageID())
case "add_client_submit_disable", "add_client_submit_enable":
// Both submits land here and differ only in the enable flag; the data is
// short enough never to be hashed, so its raw form is the discriminator.
draft.enable = callbackQuery.Data == "add_client_submit_enable"
if _, err := t.SubmitAddClient(draft); err != nil {
t.sendNotice(chatId, t.I18nBot("tgbot.messages.error_add_client", "error=="+err.Error()))
return
}
email := draft.email
addClientDrafts.reset(actor)
// The new client's card is the screen: the links and the QR hang off it,
// so nobody has to go looking for the client they just created.
t.searchClientScreen(chatId, email)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
case "reset_all_traffics_cancel":
t.renderScreen(chatId, t.newScreen("main", t.I18nBot("tgbot.messages.cancel"), t.homeRows()...))
case "reset_all_traffics_c":
emails, err := t.inboundService.GetAllEmails()
if err != nil {
t.sendNotice(chatId, t.I18nBot("tgbot.answers.errorOperation"))
return
}
// One report per tap, not one message per client: a large panel would
// otherwise burst past Telegram's rate limit.
var report strings.Builder
for _, email := range emails {
if err := t.inboundService.ResetClientTrafficByEmail(email); err == nil {
report.WriteString(t.I18nBot("tgbot.messages.SuccessResetTraffic", "ClientEmail=="+email))
} else {
report.WriteString(t.I18nBot("tgbot.messages.FailedResetTraffic", "ClientEmail=="+email, "ErrorMessage=="+err.Error()))
}
report.WriteString("\r\n\r\n")
}
report.WriteString(t.I18nBot("tgbot.messages.FinishProcess"))
// Paged: past ~80 clients the report exceeds one message.
t.sendPagedNotice(chatId, report.String())
// The confirmation has served its purpose: leaving it on screen left the
// only button being the one just pressed.
t.screenHome(chatId, callbackQuery.From.ID)
default:
action, email, ok := splitClientLinkCallback(callbackQuery.Data)
if !ok {
// Nothing matched: an unknown button still has to be answered, or it
// keeps spinning until Telegram times the callback out.
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
// The keyboard outlives the chat it was sent to, so the email in it
// cannot authorise itself: a non-admin only reaches their own clients.
if !isAdmin && !t.clientOwnedByTgUser(callbackQuery.From.ID, email) {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
switch action {
case "client_sub_links":
t.screenClientLinks(chatId, email)
case "client_individual_links":
t.screenIndividualLinks(chatId, email)
case "client_qr_links":
t.screenClientQR(chatId, email)
}
}
}
// checkAdmin checks if the given Telegram ID is an admin.
func checkAdmin(tgId int64) bool {
return slices.Contains(adminSnapshot(), tgId)
}
// isClientSelfCallback reports whether a callback is per-user rather than
// admin-only; the caller still has to prove the client is its own.
func isClientSelfCallback(data string) bool {
switch data {
// "hide" is how any recipient puts a message away: it carries no target of its
// own (it is the message it sits on), so it is safe for anyone to reach.
case "home", "hide", "client_traffic", "client_commands", "client_sub_links",
"client_individual_links", "client_qr_links":
return true
}
_, _, ok := splitClientLinkCallback(data)
return ok
}
// splitClientLinkCallback splits "<action> <email>" for the per-client link
// callbacks; ok is false for every other data.
func splitClientLinkCallback(data string) (action, email string, ok bool) {
for _, candidate := range []string{"client_sub_links", "client_individual_links", "client_qr_links"} {
if rest, found := strings.CutPrefix(data, candidate+" "); found && rest != "" {
return candidate, rest, true
}
}
return "", "", false
}
// refuseGroups keeps every update in a private chat: a group shares one screen and keyboard among its
// members, so admins and clients would overwrite each other.
func refuseGroups(h *th.BotHandler, t *Tgbot) {
h.Use(func(ctx *th.Context, update telego.Update) error {
if chatType := updateChatType(update); chatType != "" && chatType != telego.ChatTypePrivate {
t.refuseNonPrivateChat(update)
return nil
}
return ctx.Next(update)
})
}
// updateChatType reports a message or callback chat kind, "channel" for an inline query from a channel the
// sender is not in; an inline query's "sender" type counts as private.
func updateChatType(update telego.Update) string {
switch {
case update.Message != nil:
return update.Message.Chat.Type
case update.CallbackQuery != nil && update.CallbackQuery.Message != nil:
return update.CallbackQuery.Message.GetChat().Type
case update.InlineQuery != nil:
if update.InlineQuery.ChatType == "" || update.InlineQuery.ChatType == "sender" {
return telego.ChatTypePrivate
}
return update.InlineQuery.ChatType
}
return ""
}
// refuseNonPrivateChat answers a group or channel that the bot refuses to serve.
func (t *Tgbot) refuseNonPrivateChat(update telego.Update) {
if update.InlineQuery != nil {
t.answerInline(update.InlineQuery.ID, nil, "")
return
}
chatID := int64(0)
if update.Message != nil {
chatID = update.Message.Chat.ID
} else if update.CallbackQuery != nil && update.CallbackQuery.Message != nil {
chatID = update.CallbackQuery.Message.GetChat().ID
t.answerSilent(update.CallbackQuery.ID)
}
if chatID != 0 {
t.sendNoticeNoKeyboard(chatID, t.I18nBot("tgbot.messages.privateOnly"))
}
}