diff --git a/internal/web/service/tgbot/tgbot.go b/internal/web/service/tgbot/tgbot.go index 54f2e9cc0..1f9d0f5fd 100644 --- a/internal/web/service/tgbot/tgbot.go +++ b/internal/web/service/tgbot/tgbot.go @@ -63,26 +63,63 @@ var ( timestamp time.Time mutex sync.RWMutex } - - // clients data to adding new client. receiver_inbound_IDs is the set of - // inbounds the new client will be attached to; receiver_inbound_ID mirrors - // the primary pick for the legacy attach-picker entry point. Per-protocol - // secrets (UUID, password, flow, method) are filled per-inbound on submit - // by ClientService.fillProtocolDefaults, so the bot only tracks universal - // client fields here. - receiver_inbound_ID int - receiver_inbound_IDs []int - client_Email string - client_LimitIP int - client_TotalGB int64 - client_ExpiryTime int64 - client_Enable bool - client_TgID string - client_SubID string - client_Comment string - client_Reset int ) +// clientDraft is one chat's add-client wizard state. Per-protocol secrets are +// filled per-inbound on submit, so only the universal fields live here. +type clientDraft struct { + sync.Mutex + receiverInboundID int + receiverInboundIDs []int + email string + limitIP int + totalGB int64 + expiryTime int64 + enable bool + tgID string + subID string + comment string + reset int +} + +// clientDrafts keys a draft by chat: the steps arrive on the worker pool, so a +// single draft let two admins fill in one client between them. +type clientDrafts struct { + mu sync.Mutex + drafts map[int64]*clientDraft +} + +var addClientDrafts = &clientDrafts{drafts: make(map[int64]*clientDraft)} + +func (s *clientDrafts) forChat(chatID int64) *clientDraft { + s.mu.Lock() + defer s.mu.Unlock() + draft, ok := s.drafts[chatID] + if !ok { + draft = &clientDraft{} + s.drafts[chatID] = draft + } + return draft +} + +func (s *clientDrafts) reset(chatID int64) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.drafts, chatID) +} + +// isAddClientStep reports whether callback data belongs to the add-client +// wizard, the only flow that reads or writes a draft. +func isAddClientStep(data string) bool { + return strings.HasPrefix(data, "add_client") +} + +func (s *clientDrafts) resetAll() { + s.mu.Lock() + defer s.mu.Unlock() + s.drafts = make(map[int64]*clientDraft) +} + // userStateStore guards the per-chat conversation states. The Telegram command // and callback handlers run on a worker-pool goroutine while the message handler // runs on the dispatch goroutine, so a bare map would be a concurrent-map-write @@ -482,6 +519,7 @@ func StopBot() { tgBotMutex.Unlock() userStateMgr.reset() + addClientDrafts.resetAll() if handler != nil { _ = handler.Stop() diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go index 07c4ffe59..c89012cc8 100644 --- a/internal/web/service/tgbot/tgbot_client.go +++ b/internal/web/service/tgbot/tgbot_client.go @@ -30,52 +30,52 @@ import ( // 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() string { +func (t *Tgbot) BuildClientDraftMessage(draft *clientDraft) string { now := time.Now().UnixMilli() expiry := "" switch { - case client_ExpiryTime == 0: + case draft.expiryTime == 0: expiry = t.I18nBot("tgbot.unlimited") - case client_ExpiryTime < 0: - expiry = fmt.Sprintf("%d %s", client_ExpiryTime/-86400000, t.I18nBot("tgbot.days")) + case draft.expiryTime < 0: + expiry = fmt.Sprintf("%d %s", draft.expiryTime/-86400000, t.I18nBot("tgbot.days")) default: - diff := client_ExpiryTime - now + diff := draft.expiryTime - now if diff > 172800000 { - expiry = time.UnixMilli(client_ExpiryTime).Format("2006-01-02 15:04:05") + 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 client_TotalGB > 0 { - traffic = common.FormatTraffic(client_TotalGB) + if draft.totalGB > 0 { + traffic = common.FormatTraffic(draft.totalGB) } ipLimit := "♾️ Unlimited(Reset)" - if client_LimitIP > 0 { - ipLimit = fmt.Sprint(client_LimitIP) + if draft.limitIP > 0 { + ipLimit = fmt.Sprint(draft.limitIP) } - attached := t.describeAttachedInbounds(receiver_inbound_IDs) + attached := t.describeAttachedInbounds(draft.receiverInboundIDs) if attached == "" { attached = "—" } - comment := client_Comment + comment := draft.comment if comment == "" { comment = "—" } - tgID := client_TgID + tgID := draft.tgID if tgID == "" { tgID = "—" } var b strings.Builder b.WriteString("📝 New client draft\r\n") - fmt.Fprintf(&b, "📧 Email: %s\r\n", html.EscapeString(client_Email)) + fmt.Fprintf(&b, "📧 Email: %s\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) @@ -111,25 +111,25 @@ func (t *Tgbot) describeAttachedInbounds(ids []int) string { // 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() (bool, error) { - inboundIDs := receiver_inbound_IDs - if len(inboundIDs) == 0 && receiver_inbound_ID > 0 { - inboundIDs = []int{receiver_inbound_ID} +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(client_TgID, 10, 64) + tgIDInt, _ := strconv.ParseInt(draft.tgID, 10, 64) client := model.Client{ - Email: client_Email, - Enable: client_Enable, - LimitIP: client_LimitIP, - TotalGB: client_TotalGB, - ExpiryTime: client_ExpiryTime, - SubID: client_SubID, - Comment: client_Comment, - Reset: client_Reset, + 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, } @@ -761,8 +761,8 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) { // 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() [][]telego.InlineKeyboardButton { - attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(receiver_inbound_IDs)) +func (t *Tgbot) getCommonClientButtons(draft *clientDraft) [][]telego.InlineKeyboardButton { + attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(draft.receiverInboundIDs)) return [][]telego.InlineKeyboardButton{ tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), @@ -790,8 +790,8 @@ func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton { } // addClient renders the draft message + shared client-first keyboard. -func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) { - inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons()...) +func (t *Tgbot) addClient(chatId int64, draft *clientDraft, msg string, messageID ...int) { + inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons(draft)...) if len(messageID) > 0 { t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard) } else { diff --git a/internal/web/service/tgbot/tgbot_client_draft_per_chat_test.go b/internal/web/service/tgbot/tgbot_client_draft_per_chat_test.go new file mode 100644 index 000000000..548beee66 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_client_draft_per_chat_test.go @@ -0,0 +1,178 @@ +package tgbot + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + + "github.com/mymmrac/telego" +) + +// draftTexts serves the methods the add-client wizard touches and records the +// text of every sendMessage and editMessageText per chat. +func draftTexts(t *testing.T) (string, func(int64) []string) { + t.Helper() + var mu sync.Mutex + texts := map[int64][]string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + result := any(true) + if r.URL.Path == "/bot"+testBotToken+"/sendMessage" || r.URL.Path == "/bot"+testBotToken+"/editMessageText" { + var payload struct { + ChatID any `json:"chat_id"` + Text string `json:"text"` + } + _ = json.Unmarshal(body, &payload) + chatID := int64(0) + switch v := payload.ChatID.(type) { + case float64: + chatID = int64(v) + } + mu.Lock() + texts[chatID] = append(texts[chatID], payload.Text) + mu.Unlock() + result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": chatID, "type": "private"}} + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result}) + })) + t.Cleanup(srv.Close) + + return srv.URL, func(chatID int64) []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), texts[chatID]...) + } +} + +// cardEmail reads the email off a rendered draft card, which is the field the +// wizard assigns when the flow starts. +func cardEmail(t *testing.T, card string) string { + t.Helper() + const marker = "Email: " + start := strings.Index(card, marker) + if start < 0 { + t.Fatalf("not a draft card: %q", card) + } + rest := card[start+len(marker):] + end := strings.Index(rest, "") + if end < 0 { + t.Fatalf("card has an unterminated email: %q", card) + } + return rest[:end] +} + +func lastDraftCard(t *testing.T, texts []string) string { + t.Helper() + for i := len(texts) - 1; i >= 0; i-- { + if strings.Contains(texts[i], "Email: ") { + return texts[i] + } + } + t.Fatal("no draft card reached the chat") + return "" +} + +// Regression test: one package-level draft per bot meant an admin's new client +// was filled in by another chat's steps. +func TestAddClientDraftIsPerChat(t *testing.T) { + if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + const ( + chatA = int64(7101) + chatB = int64(7202) + ) + url, textsFor := draftTexts(t) + swapTestBot(t, url) + origRunning := isRunning + t.Cleanup(func() { isRunning = origRunning }) + isRunning = true + + callback := func(chatID int64, data string) { + t.Helper() + (&Tgbot{}).answerCallback(&telego.CallbackQuery{ + ID: "q1", + From: telego.User{ID: 1}, + Data: data, + Message: &telego.Message{MessageID: 7, Chat: telego.Chat{ID: chatID}}, + }, true) + } + + // Both admins start a client; each card carries the email the wizard just + // generated for that chat. + callback(chatA, "add_client_to 1") + callback(chatB, "add_client_to 2") + emailA := cardEmail(t, lastDraftCard(t, textsFor(chatA))) + emailB := cardEmail(t, lastDraftCard(t, textsFor(chatB))) + if emailA == "" || emailA == emailB { + t.Fatalf("drafts start with the same email %q, want one per chat", emailA) + } + + // Chat A renders its card again, with chat B's wizard already past its start. + callback(chatA, "add_client_default_traffic_exp") + + if got := cardEmail(t, lastDraftCard(t, textsFor(chatA))); got != emailA { + t.Errorf("chat A's card shows email %q, want its own %q from chat B's draft", got, emailA) + } + if got := cardEmail(t, lastDraftCard(t, textsFor(chatB))); got != emailB { + t.Errorf("chat B's card shows email %q, want %q", got, emailB) + } +} + +// Regression test: the draft's lock and map were reached before the admin gate, so +// a report tap queued behind a wizard and any chat a tap came from got stored. +func TestNonWizardCallbackTakesNoDraftLock(t *testing.T) { + const ( + heldChat = int64(7303) + spareChat = int64(7404) + ) + decliningServer(t) + + held := addClientDrafts.forChat(heldChat) + held.Lock() + defer held.Unlock() + + tap := func(chatID int64, isAdmin bool, data string) { + (&Tgbot{}).answerCallback(&telego.CallbackQuery{ + ID: "q1", + From: telego.User{ID: 1}, + Data: data, + Message: &telego.Message{Chat: telego.Chat{ID: chatID}}, + }, isAdmin) + } + returns := func(what string, tap func()) { + t.Helper() + done := make(chan struct{}) + go func() { + defer close(done) + tap() + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("%s waited on the draft lock it never reads", what) + } + } + + returns("an admin report tap", func() { tap(heldChat, true, "no_such_admin_action 5") }) + returns("a non-admin wizard tap", func() { tap(heldChat, false, "add_client_to 1") }) + tap(spareChat, false, "add_client_to 1") + + addClientDrafts.mu.Lock() + _, stored := addClientDrafts.drafts[spareChat] + addClientDrafts.mu.Unlock() + if stored { + t.Errorf("draft stored for chat %d, want none until its wizard starts", spareChat) + } +} diff --git a/internal/web/service/tgbot/tgbot_draft_render_test.go b/internal/web/service/tgbot/tgbot_draft_render_test.go index 05ce3dff0..6c6eb00d9 100644 --- a/internal/web/service/tgbot/tgbot_draft_render_test.go +++ b/internal/web/service/tgbot/tgbot_draft_render_test.go @@ -17,25 +17,23 @@ import ( "golang.org/x/text/language" ) +// clientDraftTestChatID is a chat id no other test drives, so the draft this +// test fills cannot leak into them. +const clientDraftTestChatID = -9001 + // Regression test: the draft is sent with ParseMode HTML, so Markdown markers // were rendered literally and an unescaped value could break the whole message. func TestClientDraftMessageRendersHTML(t *testing.T) { - origEmail, origComment, origTgID := client_Email, client_Comment, client_TgID - origTotalGB, origLimitIP, origExpiry := client_TotalGB, client_LimitIP, client_ExpiryTime - origInboundIDs := receiver_inbound_IDs - t.Cleanup(func() { - client_Email, client_Comment, client_TgID = origEmail, origComment, origTgID - client_TotalGB, client_LimitIP, client_ExpiryTime = origTotalGB, origLimitIP, origExpiry - receiver_inbound_IDs = origInboundIDs - }) + draft := addClientDrafts.forChat(clientDraftTestChatID) + t.Cleanup(func() { addClientDrafts.reset(clientDraftTestChatID) }) - client_Email = "a@b.c" - client_Comment = "promo & <10 GB>" - client_TgID = "42" - client_TotalGB, client_LimitIP, client_ExpiryTime = 0, 0, 0 - receiver_inbound_IDs = nil + draft.email = "a@b.c" + draft.comment = "promo & <10 GB>" + draft.tgID = "42" + draft.totalGB, draft.limitIP, draft.expiryTime = 0, 0, 0 + draft.receiverInboundIDs = nil - out := (&Tgbot{}).BuildClientDraftMessage() + out := (&Tgbot{}).BuildClientDraftMessage(draft) if !strings.Contains(out, "New client draft") { t.Errorf("draft title is not HTML markup: %q", out) @@ -46,7 +44,7 @@ func TestClientDraftMessageRendersHTML(t *testing.T) { if strings.Contains(out, "promo") { t.Errorf("raw comment markup reached the message: %q", out) } - if !strings.Contains(out, html.EscapeString(client_Comment)) { + if !strings.Contains(out, html.EscapeString(draft.comment)) { t.Errorf("comment is not HTML-escaped: %q", out) } } @@ -104,10 +102,10 @@ func TestAddClientPromptsEscapeDraftValues(t *testing.T) { url, texts := promptTexts(t) swapTestBot(t, url) - origEmail, origComment := client_Email, client_Comment + draft := addClientDrafts.forChat(1) origRunning := isRunning t.Cleanup(func() { - client_Email, client_Comment = origEmail, origComment + addClientDrafts.reset(1) isRunning = origRunning }) isRunning = true @@ -123,7 +121,7 @@ func TestAddClientPromptsEscapeDraftValues(t *testing.T) { tb := &Tgbot{} for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - client_Email, client_Comment = tc.value, tc.value + draft.email, draft.comment = tc.value, tc.value tb.answerCallback(&telego.CallbackQuery{ ID: "q1", diff --git a/internal/web/service/tgbot/tgbot_inbound.go b/internal/web/service/tgbot/tgbot_inbound.go index c3b23748e..28b66ad79 100644 --- a/internal/web/service/tgbot/tgbot_inbound.go +++ b/internal/web/service/tgbot/tgbot_inbound.go @@ -185,7 +185,7 @@ func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) { // current selection state for the inbound; tapping fires // add_client_toggle_attach which flips it and re-renders. A final // "Done" button (add_client_attach_done) returns to the field-edit screen. -func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error) { +func (t *Tgbot) getInboundsAttachPicker(draft *clientDraft) (*telego.InlineKeyboardMarkup, error) { inbounds, err := t.inboundService.GetAllInbounds() if err != nil { logger.Warning("GetAllInbounds run failed:", err) @@ -201,8 +201,8 @@ func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error) model.AmneziaWG: true, model.HTTP: true, } - selected := make(map[int]bool, len(receiver_inbound_IDs)) - for _, id := range receiver_inbound_IDs { + selected := make(map[int]bool, len(draft.receiverInboundIDs)) + for _, id := range draft.receiverInboundIDs { selected[id] = true } var buttons []telego.InlineKeyboardButton diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go index 4b00e85d5..fc0839c64 100644 --- a/internal/web/service/tgbot/tgbot_router.go +++ b/internal/web/service/tgbot/tgbot_router.go @@ -114,16 +114,20 @@ func (t *Tgbot) OnReceive() { defer recoverBotPanic() userStateMgr.maybePrune(time.Hour) if userState, exists := userStateMgr.get(message.Chat.ID); exists { + // Only a wizard step touches the draft, so only it takes the lock. + draft := addClientDrafts.forChat(message.Chat.ID) + draft.Lock() + defer draft.Unlock() switch userState { case "awaiting_email": - if client_Email == strings.TrimSpace(message.Text) { + if draft.email == strings.TrimSpace(message.Text) { t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) userStateMgr.clear(message.Chat.ID) return nil } - client_Email = strings.TrimSpace(message.Text) - if t.isSingleWord(client_Email) { + draft.email = strings.TrimSpace(message.Text) + if t.isSingleWord(draft.email) { userStateMgr.set(message.Chat.ID, "awaiting_email") cancel_btn_markup := tu.InlineKeyboard( @@ -136,26 +140,26 @@ func (t *Tgbot) OnReceive() { } else { t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove()) userStateMgr.clear(message.Chat.ID) - t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) + t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft)) } case "awaiting_comment": - if client_Comment == strings.TrimSpace(message.Text) { + if draft.comment == strings.TrimSpace(message.Text) { t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) userStateMgr.clear(message.Chat.ID) return nil } - client_Comment = strings.TrimSpace(message.Text) + draft.comment = strings.TrimSpace(message.Text) t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove()) userStateMgr.clear(message.Chat.ID) - t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) + 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") { - client_TgID = "" + draft.tgID = "" t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) userStateMgr.clear(message.Chat.ID) - t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) + t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft)) return nil } if _, err := strconv.ParseInt(input, 10, 64); err != nil { @@ -167,10 +171,10 @@ func (t *Tgbot) OnReceive() { t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup) return nil } - client_TgID = input + draft.tgID = input t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.userSaved"), 3, tu.ReplyKeyboardRemove()) userStateMgr.clear(message.Chat.ID) - t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) + t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft)) } } else { if message.UsersShared != nil { @@ -312,6 +316,15 @@ func isCommandForBot(text string, username string) bool { func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool) { chatId := callbackQuery.Message.GetChat().ID + // 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.forChat(chatId) + draft.Lock() + defer draft.Unlock() + } + if isAdmin { // get query from hash storage decodedQuery, err := t.decodeQuery(callbackQuery.Data) @@ -472,11 +485,11 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) case "add_client_limit_traffic_c": limitTraffic, _ := strconv.ParseInt(dataArray[1], 10, 64) - client_TotalGB = limitTraffic * 1024 * 1024 * 1024 + draft.totalGB = limitTraffic * 1024 * 1024 * 1024 messageId := callbackQuery.Message.GetMessageID() - message_text := t.BuildClientDraftMessage() + message_text := t.BuildClientDraftMessage(draft) - t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) + 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 { @@ -599,24 +612,24 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) case "add_client_reset_exp_c": - client_ExpiryTime = 0 + draft.expiryTime = 0 days, _ := strconv.ParseInt(dataArray[1], 10, 64) var date int64 - if client_ExpiryTime > 0 { - if client_ExpiryTime-time.Now().Unix()*1000 < 0 { + if draft.expiryTime > 0 { + if draft.expiryTime-time.Now().Unix()*1000 < 0 { date = -(days * 24 * 60 * 60000) } else { - date = client_ExpiryTime + days*24*60*60000 + date = draft.expiryTime + days*24*60*60000 } } else { - date = client_ExpiryTime - days*24*60*60000 + date = draft.expiryTime - days*24*60*60000 } - client_ExpiryTime = date + draft.expiryTime = date messageId := callbackQuery.Message.GetMessageID() - message_text := t.BuildClientDraftMessage() + message_text := t.BuildClientDraftMessage(draft) - t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) + 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 { @@ -717,13 +730,13 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool case "add_client_ip_limit_c": if len(dataArray) == 2 { count, _ := strconv.Atoi(dataArray[1]) - client_LimitIP = count + draft.limitIP = count } messageId := callbackQuery.Message.GetMessageID() - message_text := t.BuildClientDraftMessage() + message_text := t.BuildClientDraftMessage(draft) - t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) + 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 { @@ -843,15 +856,15 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clients) case "add_client_to": - client_Email = t.randomLowerAndNum(8) - client_LimitIP = 0 - client_TotalGB = 0 - client_ExpiryTime = 0 - client_Enable = true - client_TgID = "" - client_SubID = t.randomLowerAndNum(16) - client_Comment = "" - client_Reset = 0 + draft.email = t.randomLowerAndNum(8) + draft.limitIP = 0 + draft.totalGB = 0 + draft.expiryTime = 0 + draft.enable = true + draft.tgID = "" + draft.subID = t.randomLowerAndNum(16) + draft.comment = "" + draft.reset = 0 inboundId := dataArray[1] inboundIdInt, err := strconv.Atoi(inboundId) @@ -859,9 +872,9 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) return } - receiver_inbound_ID = inboundIdInt - receiver_inbound_IDs = []int{inboundIdInt} - t.addClient(callbackQuery.Message.GetChat().ID, t.BuildClientDraftMessage()) + 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) @@ -870,18 +883,18 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool return } found := -1 - for i, id := range receiver_inbound_IDs { + for i, id := range draft.receiverInboundIDs { if id == inboundIdInt { found = i break } } if found >= 0 { - receiver_inbound_IDs = append(receiver_inbound_IDs[:found], receiver_inbound_IDs[found+1:]...) + draft.receiverInboundIDs = append(draft.receiverInboundIDs[:found], draft.receiverInboundIDs[found+1:]...) } else { - receiver_inbound_IDs = append(receiver_inbound_IDs, inboundIdInt) + draft.receiverInboundIDs = append(draft.receiverInboundIDs, inboundIdInt) } - picker, err := t.getInboundsAttachPicker() + picker, err := t.getInboundsAttachPicker(draft) if err != nil { t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) return @@ -1043,15 +1056,15 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands")) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpAdminCommands")) case "add_client": - client_Email = t.randomLowerAndNum(8) - client_LimitIP = 0 - client_TotalGB = 0 - client_ExpiryTime = 0 - client_Enable = true - client_TgID = "" - client_SubID = t.randomLowerAndNum(16) - client_Comment = "" - client_Reset = 0 + draft.email = t.randomLowerAndNum(8) + draft.limitIP = 0 + draft.totalGB = 0 + draft.expiryTime = 0 + draft.enable = true + draft.tgID = "" + draft.subID = t.randomLowerAndNum(16) + draft.comment = "" + draft.reset = 0 inbounds, err := t.getInboundsAddClient() if err != nil { @@ -1068,7 +1081,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), ), ) - prompt_message := t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+html.EscapeString(client_Email)) + prompt_message := t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+html.EscapeString(draft.email)) t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) case "add_client_ch_default_comment": t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) @@ -1078,7 +1091,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), ), ) - prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+html.EscapeString(client_Comment)) + prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+html.EscapeString(draft.comment)) t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) case "add_client_ch_default_tg_id": t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) @@ -1088,7 +1101,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), ), ) - current := client_TgID + current := draft.tgID if current == "" { current = "—" } @@ -1184,68 +1197,65 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) userStateMgr.clear(chatId) - t.addClient(chatId, t.BuildClientDraftMessage()) + t.addClient(chatId, draft, t.BuildClientDraftMessage(draft)) case "add_client_cancel": userStateMgr.clear(chatId) - receiver_inbound_ID = 0 - receiver_inbound_IDs = nil + addClientDrafts.reset(chatId) t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.cancel"), 3, tu.ReplyKeyboardRemove()) case "add_client_default_traffic_exp": messageId := callbackQuery.Message.GetMessageID() - message_text := t.BuildClientDraftMessage() - t.addClient(chatId, message_text, messageId) - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email)) + message_text := t.BuildClientDraftMessage(draft) + t.addClient(chatId, draft, message_text, messageId) + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+draft.email)) case "add_client_default_ip_limit": messageId := callbackQuery.Message.GetMessageID() - message_text := t.BuildClientDraftMessage() - t.addClient(chatId, message_text, messageId) - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email)) + message_text := t.BuildClientDraftMessage(draft) + t.addClient(chatId, draft, message_text, messageId) + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+draft.email)) case "add_client_attach_more": - picker, err := t.getInboundsAttachPicker() + picker, err := t.getInboundsAttachPicker(draft) if err != nil { t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) return } t.SendMsgToTgbot(chatId, "Pick inbound(s) to attach:", picker) case "add_client_attach_done": - if receiver_inbound_ID == 0 && len(receiver_inbound_IDs) > 0 { - receiver_inbound_ID = receiver_inbound_IDs[0] + if draft.receiverInboundID == 0 && len(draft.receiverInboundIDs) > 0 { + draft.receiverInboundID = draft.receiverInboundIDs[0] } - if receiver_inbound_ID == 0 { + if draft.receiverInboundID == 0 { t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getInboundsFailed")) return } - message_text := t.BuildClientDraftMessage() + message_text := t.BuildClientDraftMessage(draft) t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - t.addClient(chatId, message_text) + t.addClient(chatId, draft, message_text) case "add_client_submit_disable": - client_Enable = false - _, err := t.SubmitAddClient() + draft.enable = false + _, err := t.SubmitAddClient(draft) if err != nil { errorMessage := fmt.Sprintf("%v", err) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.error_add_client", "error=="+errorMessage), tu.ReplyKeyboardRemove()) } else { t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) - t.sendClientIndividualLinks(chatId, client_Email) - t.sendClientQRLinks(chatId, client_Email) - receiver_inbound_ID = 0 - receiver_inbound_IDs = nil + t.sendClientIndividualLinks(chatId, draft.email) + t.sendClientQRLinks(chatId, draft.email) + addClientDrafts.reset(chatId) } case "add_client_submit_enable": - client_Enable = true - _, err := t.SubmitAddClient() + draft.enable = true + _, err := t.SubmitAddClient(draft) if err != nil { errorMessage := fmt.Sprintf("%v", err) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.error_add_client", "error=="+errorMessage), tu.ReplyKeyboardRemove()) } else { t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) - t.sendClientIndividualLinks(chatId, client_Email) - t.sendClientQRLinks(chatId, client_Email) - receiver_inbound_ID = 0 - receiver_inbound_IDs = nil + t.sendClientIndividualLinks(chatId, draft.email) + t.sendClientQRLinks(chatId, draft.email) + addClientDrafts.reset(chatId) } case "reset_all_traffics_cancel": t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())