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())