diff --git a/docs/content/docs/en/operations/telegram-bot.mdx b/docs/content/docs/en/operations/telegram-bot.mdx index e0edb962c..c5e4fbcf3 100644 --- a/docs/content/docs/en/operations/telegram-bot.mdx +++ b/docs/content/docs/en/operations/telegram-bot.mdx @@ -53,13 +53,22 @@ Additional commands: | Command | Who | Action | | ------------------ | ------ | ------------------------------------------------------------ | -| `/start`, `/help` | anyone | Greeting and the menu of inline buttons | -| `/status` | anyone | Confirm the bot is alive | +| `/start` | anyone | Greeting and the menu of inline buttons; an unlinked account gets only its Telegram ID | +| `/help` | both | The menu of inline buttons | +| `/status` | both | Confirm the bot is alive | | `/id` | anyone | Show your Telegram numeric ID | | `/usage ` | both | Admins search clients; users look up their own usage | | `/inbound `| admin | Show an inbound's details | | `/restart` | admin | Restart Xray | +A user is a Telegram account linked to at least one client. Any other account +can run only `/start` and `/id`; the bot ignores its other commands and +button taps. To link a customer, tap **Invite Link** on the client's card in the bot and +send them the `t.me` link: the first account to open it is linked to every +client that shares that Subscription ID. The Subscription ID is the invite code, +so keep it long and random. Each account gets five claim attempts an hour, and +admins are notified when one runs out. + Admins also get inline-button flows for server usage, sorted traffic reports, resetting traffic, DB backups, ban logs, listing inbounds/clients, online clients, "depleting soon", and a full **add-client** wizard. Regular users get diff --git a/docs/content/docs/fa/operations/telegram-bot.mdx b/docs/content/docs/fa/operations/telegram-bot.mdx index 655734f80..dca53156a 100644 --- a/docs/content/docs/fa/operations/telegram-bot.mdx +++ b/docs/content/docs/fa/operations/telegram-bot.mdx @@ -53,13 +53,22 @@ icon: Send | فرمان | چه کسی | عملکرد | | ------------------ | ------ | ------------------------------------------------------------ | -| `/start`، `/help` | همه | پیام خوش‌آمدگویی و منوی دکمه‌های درون‌خطی | -| `/status` | همه | تأیید فعال بودن ربات | +| `/start` | همه | پیام خوش‌آمدگویی و منوی دکمه‌های درون‌خطی؛ حساب متصل‌نشده فقط شناسه‌ی Telegram خود را می‌گیرد | +| `/help` | هر دو | منوی دکمه‌های درون‌خطی | +| `/status` | هر دو | تأیید فعال بودن ربات | | `/id` | همه | نمایش شناسه‌ی عددی Telegram شما | | `/usage ` | هر دو | ادمین‌ها کلاینت‌ها را جست‌وجو می‌کنند؛ کاربران مصرف خود را می‌بینند | | `/inbound `| ادمین | نمایش جزئیات یک ورودی | | `/restart` | ادمین | راه‌اندازی مجدد Xray | +کاربر یعنی حساب Telegramی که دست‌کم به یک کلاینت متصل است. هر حساب دیگری فقط +`/start` و `/id` را می‌تواند اجرا کند و ربات فرمان‌ها و دکمه‌های دیگر آن را نادیده +می‌گیرد. برای اتصال یک مشتری، در کارت کلاینت در ربات روی **لینک دعوت** بزنید و لینک `t.me` +را برایش بفرستید: نخستین حسابی که آن را باز کند به همه‌ی کلاینت‌هایی که آن شناسه +اشتراک را دارند متصل می‌شود. شناسه اشتراک همان کد دعوت است، پس آن را طولانی و +تصادفی نگه دارید. هر حساب در هر ساعت پنج بار می‌تواند تلاش کند و پس از آن به +ادمین‌ها اطلاع داده می‌شود. + ادمین‌ها همچنین جریان‌های دکمه‌ی درون‌خطی برای مصرف سرور، گزارش‌های ترافیک مرتب‌شده، بازنشانی ترافیک، پشتیبان‌گیری از DB، گزارش‌های مسدودسازی، فهرست کردن ورودی‌ها/کلاینت‌ها، کلاینت‌های آنلاین، «به‌زودی تمام‌شونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند. diff --git a/docs/content/docs/ru/operations/telegram-bot.mdx b/docs/content/docs/ru/operations/telegram-bot.mdx index 2d0c04d39..d9a92a74e 100644 --- a/docs/content/docs/ru/operations/telegram-bot.mdx +++ b/docs/content/docs/ru/operations/telegram-bot.mdx @@ -55,13 +55,23 @@ chat ID** (через запятую). Сохраните, затем напиш | Команда | Кому | Действие | | ------------------ | ------ | ------------------------------------------------------------ | -| `/start`, `/help` | всем | Приветствие и меню встроенных кнопок | -| `/status` | всем | Подтверждает, что бот работает | +| `/start` | всем | Приветствие и меню встроенных кнопок; непривязанный аккаунт получает только свой Telegram ID | +| `/help` | обоим | Меню встроенных кнопок | +| `/status` | обоим | Подтверждает, что бот работает | | `/id` | всем | Показывает ваш числовой Telegram ID | | `/usage ` | обоим | Администраторы ищут клиентов; пользователи смотрят свой расход | | `/inbound `| админ | Показывает сведения о входящем подключении | | `/restart` | админ | Перезапускает Xray | +Пользователь — это аккаунт Telegram, привязанный хотя бы к одному клиенту. Любой +другой аккаунт может выполнять только `/start` и `/id`; остальные его команды и +нажатия кнопок бот игнорирует. Чтобы привязать клиента, нажмите +**Ссылка-приглашение** в карточке клиента в боте и отправьте ему ссылку `t.me`: первый +открывший её аккаунт привязывается ко всем клиентам с этим ID подписки. ID +подписки служит кодом приглашения, поэтому делайте его длинным и случайным. +У каждого аккаунта пять попыток в час, после чего администраторы получают +уведомление. + Администраторам также доступны сценарии со встроенными кнопками: использование сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД, журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты, diff --git a/docs/content/docs/zh/operations/telegram-bot.mdx b/docs/content/docs/zh/operations/telegram-bot.mdx index d48e6d2a7..6272450a6 100644 --- a/docs/content/docs/zh/operations/telegram-bot.mdx +++ b/docs/content/docs/zh/operations/telegram-bot.mdx @@ -51,13 +51,20 @@ icon: Send | 命令 | 适用对象 | 作用 | | ------------------ | ------ | ------------------------------------------------------------ | -| `/start`、`/help` | 任何人 | 问候语以及内联按钮菜单 | -| `/status` | 任何人 | 确认机器人在线 | +| `/start` | 任何人 | 问候语以及内联按钮菜单;未绑定的账号只会收到自己的 Telegram ID | +| `/help` | 两者 | 内联按钮菜单 | +| `/status` | 两者 | 确认机器人在线 | | `/id` | 任何人 | 显示你的 Telegram 数字 ID | | `/usage ` | 两者 | 管理员可搜索客户端;用户则查询自己的用量 | | `/inbound `| 管理员 | 显示某个入站的详情 | | `/restart` | 管理员 | 重启 Xray | +用户是指至少绑定了一个客户端的 Telegram 账号。其他账号只能使用 `/start` 和 +`/id`,机器人会忽略它们的其他命令和按钮点击。要绑定客户,请在机器人的客户端卡片上点击 +**邀请链接**,并把 `t.me` 链接发给对方:第一个打开该链接的账号会绑定到共用该订阅 +ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长且随机。每个账号每小时 +可尝试五次,用完后会通知管理员。 + 管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、 重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、 “即将耗尽”,以及完整的**添加客户端**向导。普通用户则可以使用按钮查看 diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 26f6bb427..518db74e9 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -1151,7 +1151,10 @@ export default function ClientFormModal({ - + = inviteAttemptWindow { + delete(inviteAttemptsBy, id) + } + } + a, ok := inviteAttemptsBy[from.ID] + if !ok { + a = &inviteAttempts{windowStart: now} + inviteAttemptsBy[from.ID] = a + } + a.count++ + count := a.count + inviteAttemptsMu.Unlock() + + if count == inviteAttemptLimit+1 { + t.SendMsgToTgbotAdmins(t.I18nBot("tgbot.messages.inviteRateLimitedAdmin", + "User=="+tgUserMention(from), + "ID=="+strconv.FormatInt(from.ID, 10), + "Limit=="+strconv.Itoa(inviteAttemptLimit))) + } + return count <= inviteAttemptLimit +} + +func tgUserMention(from *telego.User) string { + id := strconv.FormatInt(from.ID, 10) + name := strings.TrimSpace(from.FirstName + " " + from.LastName) + if name == "" { + name = id + } + mention := `` + html.EscapeString(name) + `` + if from.Username != "" { + mention += " @" + html.EscapeString(from.Username) + } + return mention +} diff --git a/internal/web/service/tgbot/tgbot_invite_test.go b/internal/web/service/tgbot/tgbot_invite_test.go new file mode 100644 index 000000000..7694bb4fd --- /dev/null +++ b/internal/web/service/tgbot/tgbot_invite_test.go @@ -0,0 +1,221 @@ +package tgbot + +import ( + "strings" + "sync" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + + "github.com/mymmrac/telego" +) + +func seedClientRecord(t *testing.T, email, subID string, tgID int64) { + t.Helper() + rec := &model.ClientRecord{Email: email, SubID: subID, TgID: tgID, Enable: true} + if err := database.GetDB().Create(rec).Error; err != nil { + t.Fatalf("seed client %s: %v", email, err) + } +} + +// Binding is first-claim-wins: the owner re-tapping is idempotent, and no part of +// a subscription held by someone else is ever reassigned. +func TestResolveInviteToken(t *testing.T) { + tb, _ := newLinksCallbackTgbot(t, ownerMail) + seedClientRecord(t, "free@x", "sub-free", 0) + seedClientRecord(t, "held@x", "sub-held", 5150) + seedClientRecord(t, "shared-a@x", "sub-shared", 0) + seedClientRecord(t, "shared-b@x", "sub-shared", 0) + seedClientRecord(t, "part-mine@x", "sub-part-mine", 7000) + seedClientRecord(t, "part-free@x", "sub-part-mine", 0) + seedClientRecord(t, "part-free2@x", "sub-part-held", 0) + seedClientRecord(t, "part-held@x", "sub-part-held", 9999) + + cases := []struct { + name string + token string + from int64 + want inviteOutcome + records int + }{ + {"unclaimed binds", "sub-free", 7000, inviteBindable, 1}, + {"token is trimmed", " sub-free\n", 7000, inviteBindable, 1}, + {"owner is idempotent", "sub-held", 5150, inviteAlreadyOwned, 1}, + {"someone else's is refused", "sub-held", 7000, inviteTaken, 1}, + {"shared subscription binds whole", "sub-shared", 7000, inviteBindable, 2}, + {"finishing a partly owned one binds", "sub-part-mine", 7000, inviteBindable, 2}, + {"partly held by another is refused", "sub-part-held", 7000, inviteTaken, 2}, + {"unknown token", "sub-nope", 7000, inviteInvalid, 0}, + {"empty token", "", 7000, inviteInvalid, 0}, + {"missing sender", "sub-free", 0, inviteInvalid, 0}, + } + for _, c := range cases { + got, records := tb.resolveInviteToken(c.token, c.from) + if got != c.want || len(records) != c.records { + t.Errorf("%s: got (%d, %d records), want (%d, %d records)", c.name, got, len(records), c.want, c.records) + } + } +} + +func mustInvitePayload(t *testing.T, subID string) string { + t.Helper() + payload, ok := encodeInvitePayload(subID) + if !ok { + t.Fatalf("encodeInvitePayload(%q) refused", subID) + } + return payload +} + +// newInviteTgbot seeds one unbound client whose subId is sub-invite. +func newInviteTgbot(t *testing.T, email string) (*Tgbot, func(string) int) { + t.Helper() + tb, calls := newLinksCallbackTgbot(t, email) + if err := database.GetDB().Model(&model.Inbound{}).Where("1 = 1"). + Update("settings", `{"clients":[{"id":"6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f","email":"`+email+`","subId":"sub-invite"}]}`).Error; err != nil { + t.Fatalf("unbind seeded client: %v", err) + } + seedClientRecord(t, email, "sub-invite", 0) + withAdmins(t, 1) + return tb, calls +} + +// Regression test: a subId with URL metacharacters was pasted raw into the link, +// so Telegram truncated it; every legal subId that fits must survive the trip. +func TestInvitePayloadRoundTrip(t *testing.T) { + for _, subID := range []string{"a1B2c3D4e5F6g7H8", "team#1", "alice&bob", "x?y=z", "کاربر", strings.Repeat("s", 48)} { + payload, ok := encodeInvitePayload(subID) + if !ok { + t.Errorf("encodeInvitePayload(%q) refused", subID) + continue + } + if strings.Trim(payload, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != "" { + t.Errorf("payload %q for %q has characters Telegram rejects", payload, subID) + } + if got, ok := decodeInvitePayload(payload); !ok || got != subID { + t.Errorf("round trip of %q = (%q, %v)", subID, got, ok) + } + } + if _, ok := encodeInvitePayload(strings.Repeat("s", 49)); ok { + t.Error("a subId past 64 payload characters must be refused, not truncated") + } + for _, payload := range []string{"", "not base64!", " "} { + if _, ok := decodeInvitePayload(payload); ok { + t.Errorf("decodeInvitePayload(%q) accepted", payload) + } + } +} + +// Regression test: accounts opening one link at once all read TgID == 0, all bound +// and were all told so, while only the last write held; exactly one may succeed. +func TestConcurrentClaimsBindOnlyOneAccount(t *testing.T) { + tb, _ := newInviteTgbot(t, "raced@x") + payload := mustInvitePayload(t, "sub-invite") + + claimants := []int64{8101, 8102, 8103, 8104, 8105, 8106} + outcomes := make([]inviteOutcome, len(claimants)) + start := make(chan struct{}) + var wg sync.WaitGroup + for i, id := range claimants { + wg.Add(1) + go func() { + defer wg.Done() + <-start + outcomes[i] = tb.claimInvite(id, id, payload) + }() + } + // Hold the inbound write every bind needs, so all claimants resolve before any + // bind lands; otherwise the first bind outruns the rest and hides the race. + hold := database.GetDB().Begin() + if err := hold.Exec("UPDATE inbounds SET remark = remark").Error; err != nil { + t.Fatalf("hold inbound write: %v", err) + } + close(start) + time.Sleep(300 * time.Millisecond) + if err := hold.Commit().Error; err != nil { + t.Fatalf("release inbound write: %v", err) + } + wg.Wait() + + told, holders := 0, 0 + for i, id := range claimants { + if outcomes[i] == inviteBindable { + told++ + } + if tb.levelOf(id) == levelClient { + holders++ + } + } + if told != 1 || holders != 1 { + t.Errorf("%d accounts told they bound, %d holding the client; want 1 and 1", told, holders) + } +} + +// A stranger opening a valid invite link must come out of it a client. +func TestClaimInvitePromotesStrangerToClient(t *testing.T) { + const email = "invitee@x" + tb, calls := newInviteTgbot(t, email) + + const newcomer = int64(8080) + if got := tb.levelOf(newcomer); got != levelStranger { + t.Fatalf("levelOf before claim = %d, want stranger", got) + } + + tb.claimInvite(newcomer, newcomer, mustInvitePayload(t, "sub-invite")) + + if got := tb.levelOf(newcomer); got != levelClient { + t.Errorf("levelOf after claim = %d, want client", got) + } + if n := calls("sendMessage"); n != 1 { + t.Errorf("sendMessage calls = %d, want 1 confirmation", n) + } + if outcome, _ := tb.resolveInviteToken("sub-invite", 9999); outcome != inviteTaken { + t.Errorf("second claimant outcome = %d, want taken", outcome) + } +} + +// Guessing a subId must stay slow: past five attempts an hour an account is +// refused, and admins are told once per window rather than once per attempt. +func TestInviteAttemptLimit(t *testing.T) { + _, calls := newLinksCallbackTgbot(t, ownerMail) + withAdmins(t, 1, 2) + tb := &Tgbot{} + + now := time.Unix(1_700_000_000, 0) + origNow, origBy := inviteAttemptsNow, inviteAttemptsBy + inviteAttemptsNow = func() time.Time { return now } + inviteAttemptsBy = map[int64]*inviteAttempts{} + t.Cleanup(func() { inviteAttemptsNow, inviteAttemptsBy = origNow, origBy }) + + guesser := &telego.User{ID: 6666, FirstName: "x"} + for i := 1; i <= inviteAttemptLimit; i++ { + if !tb.allowInviteAttempt(guesser) { + t.Fatalf("attempt %d refused, want allowed", i) + } + } + for range 3 { + if tb.allowInviteAttempt(guesser) { + t.Fatal("attempt past the limit allowed") + } + } + if n := calls("sendMessage"); n != 2 { + t.Errorf("sendMessage calls = %d, want 2: one notice per admin, once per window", n) + } + if !tb.allowInviteAttempt(&telego.User{ID: 7777}) { + t.Error("another account was refused by the guesser's limit") + } + + now = now.Add(inviteAttemptWindow) + if !tb.allowInviteAttempt(guesser) { + t.Error("attempt after the window refused, want allowed") + } +} + +func TestTgUserMentionEscapesName(t *testing.T) { + got := tgUserMention(&telego.User{ID: 42, FirstName: "Eve", Username: "eve"}) + want := `<b>Eve</b> @eve` + if got != want { + t.Errorf("tgUserMention = %q, want %q", got, want) + } +} diff --git a/internal/web/service/tgbot/tgbot_level.go b/internal/web/service/tgbot/tgbot_level.go new file mode 100644 index 000000000..27d63f32c --- /dev/null +++ b/internal/web/service/tgbot/tgbot_level.go @@ -0,0 +1,72 @@ +package tgbot + +import ( + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// userLevel decides what the bot admits to existing at all: a Telegram account +// that no admin has bound to a client must not be able to explore the bot. +type userLevel int + +const ( + levelStranger userLevel = iota + levelClient + levelAdmin +) + +// levelOf runs on every update, a stranger's included, so it reads the indexed +// tg_id column of the clients table rather than expanding every inbound's JSON. +func (t *Tgbot) levelOf(tgUserID int64) userLevel { + if checkAdmin(tgUserID) { + return levelAdmin + } + if tgUserID <= 0 { + return levelStranger + } + records, err := t.clientService.GetRecordsByTgID(tgUserID) + if err != nil || len(records) == 0 { + return levelStranger + } + return levelClient +} + +// Commands are allowlisted rather than denied one by one: a command added later +// stays out of reach of non-admins until it is deliberately listed here. +var commandsByLevel = map[userLevel]map[string]bool{ + // /id stays open because an admin binding by hand still asks for the ChatID. + levelStranger: {"start": true, "id": true}, + levelClient: {"start": true, "help": true, "status": true, "id": true, "usage": true}, +} + +func commandAllowed(level userLevel, command string) bool { + if level == levelAdmin { + return true + } + return commandsByLevel[level][command] +} + +// gateCommand reports whether a command reaches answerCommand, and as whom. A +// stranger's refused command gets no reply, so the bot reveals nothing to probe. +func (t *Tgbot) gateCommand(message *telego.Message) (isAdmin bool, ok bool) { + level := t.levelOf(message.From.ID) + command, _, _ := tu.ParseCommand(message.Text) + if commandAllowed(level, command) { + return level == levelAdmin, true + } + if level == levelClient { + t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.commands.unknown")) + } + return false, false +} + +// gateCallback answers a stranger's tap without acting on it: a stranger holds +// no keyboard of ours, so any callback data from one is forged or stale. +func (t *Tgbot) gateCallback(query *telego.CallbackQuery) (isAdmin bool, ok bool) { + level := t.levelOf(query.From.ID) + if level == levelStranger { + t.sendCallbackAnswerTgBot(query.ID, "") + return false, false + } + return level == levelAdmin, true +} diff --git a/internal/web/service/tgbot/tgbot_level_test.go b/internal/web/service/tgbot/tgbot_level_test.go new file mode 100644 index 000000000..dfd650618 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_level_test.go @@ -0,0 +1,100 @@ +package tgbot + +import ( + "testing" + + "github.com/mymmrac/telego" +) + +func withAdmins(t *testing.T, ids ...int64) { + t.Helper() + tgBotMutex.Lock() + orig := adminIds + adminIds = ids + tgBotMutex.Unlock() + t.Cleanup(func() { + tgBotMutex.Lock() + adminIds = orig + tgBotMutex.Unlock() + }) +} + +// newLevelTgbot binds ownerMail to ownerTgID in both the inbound settings and +// the clients table, and makes account 1 the only admin. +func newLevelTgbot(t *testing.T) (*Tgbot, func(string) int) { + t.Helper() + tb, calls := newLinksCallbackTgbot(t, ownerMail) + seedClientRecord(t, ownerMail, "sub-owned", ownerTgID) + withAdmins(t, 1) + return tb, calls +} + +func commandFrom(tgUserID int64, text string) *telego.Message { + return &telego.Message{ + From: &telego.User{ID: tgUserID}, + Chat: telego.Chat{ID: tgUserID, Type: telego.ChatTypePrivate}, + Text: text, + } +} + +func TestLevelOfFollowsTheClientBinding(t *testing.T) { + tb, _ := newLevelTgbot(t) + + for id, want := range map[int64]userLevel{1: levelAdmin, ownerTgID: levelClient, 777: levelStranger, 0: levelStranger} { + if got := tb.levelOf(id); got != want { + t.Errorf("levelOf(%d) = %d, want %d", id, got, want) + } + } +} + +// A stranger's refused command must get no reply at all, while a bound client +// is still told the command is unknown, as before the gate existed. +func TestGateCommand(t *testing.T) { + cases := []struct { + name string + from int64 + text string + wantOK bool + wantAdmin bool + wantSends int + }{ + {"stranger start", 777, "/start", true, false, 0}, + {"stranger help", 777, "/help", false, false, 0}, + {"stranger admin command", 777, "/restart", false, false, 0}, + {"client usage", ownerTgID, "/usage", true, false, 0}, + {"client admin command", ownerTgID, "/clearall", false, false, 1}, + {"admin", 1, "/clearall", true, true, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + tb, calls := newLevelTgbot(t) + + isAdmin, ok := tb.gateCommand(commandFrom(c.from, c.text)) + if ok != c.wantOK || isAdmin != c.wantAdmin { + t.Errorf("gateCommand = (%v, %v), want (%v, %v)", isAdmin, ok, c.wantAdmin, c.wantOK) + } + if n := calls("sendMessage"); n != c.wantSends { + t.Errorf("sendMessage calls = %d, want %d", n, c.wantSends) + } + }) + } +} + +// Regression test: a stranger's forged callback must be answered, so the button +// stops spinning, and must never reach answerCallback. +func TestGateCallbackStopsStrangers(t *testing.T) { + tb, calls := newLevelTgbot(t) + + query := &telego.CallbackQuery{ID: "q1", From: telego.User{ID: 777}, Data: "client_sub_links " + ownerMail} + if _, ok := tb.gateCallback(query); ok { + t.Fatal("gateCallback admitted a stranger") + } + if n := calls("answerCallbackQuery"); n != 1 { + t.Errorf("answerCallbackQuery calls = %d, want 1", n) + } + + query.From.ID = ownerTgID + if isAdmin, ok := tb.gateCallback(query); !ok || isAdmin { + t.Errorf("gateCallback(client) = (%v, %v), want (false, true)", isAdmin, ok) + } +} diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go index 285119665..eda8bb934 100644 --- a/internal/web/service/tgbot/tgbot_router.go +++ b/internal/web/service/tgbot/tgbot_router.go @@ -96,7 +96,9 @@ func (t *Tgbot) OnReceive() { // Use goroutine with worker pool for concurrent command processing go runBotHandler(func() { userStateMgr.clear(messageActor(message)) - t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID)) + if isAdmin, ok := t.gateCommand(&message); ok { + t.answerCommand(&message, message.Chat.ID, isAdmin) + } }) return nil }, th.AnyCommand()) @@ -105,7 +107,9 @@ func (t *Tgbot) OnReceive() { // Use goroutine with worker pool for concurrent callback processing go runBotHandler(func() { userStateMgr.clear(callbackActor(&query)) - t.answerCallback(&query, checkAdmin(query.From.ID)) + if isAdmin, ok := t.gateCallback(&query); ok { + t.answerCallback(&query, isAdmin) + } }) return nil }, th.AnyCallbackQueryWithMessage()) @@ -227,6 +231,18 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo msg += t.I18nBot("tgbot.commands.help") msg += t.I18nBot("tgbot.commands.pleaseChoose") case "start": + if len(commandArgs) > 0 { + if !isAdmin && !t.allowInviteAttempt(message.From) { + t.SendMsgToTgbot(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 && t.levelOf(message.From.ID) == levelStranger { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(message.From.ID, 10))) + return + } msg += t.I18nBot("tgbot.commands.start", "Firstname=="+html.EscapeString(message.From.FirstName)) if isAdmin { msg += t.I18nBot("tgbot.commands.welcome", "Hostname=="+hostname) @@ -792,6 +808,9 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool case "tg_user": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getUserInfo", "Email=="+email)) t.clientTelegramUserInfo(chatId, email) + case "client_invite_link": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.inviteLink")) + t.sendInviteLink(chatId, email) case "tgid_remove": inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 92808efb6..de8231aca 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -791,6 +791,7 @@ "password": "كلمة المرور", "passwordDesc": "تُستخدم فقط من قبل عملاء Trojan و Shadowsocks؛ ويتم تجاهلها لـ VLESS و VMess و Hysteria و WireGuard.", "subId": "معرّف الاشتراك", + "subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.", "online": "متصل", "email": "البريد", "emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم", @@ -2468,6 +2469,11 @@ "download": "🔽 التنزيل: ↓{{ .Download }}\r\n", "total": "📊 الإجمالي: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 مستخدم Telegram: {{ .TelegramID }}\r\n", + "inviteBound": "✅ تم ربط حساب تيليجرام الخاص بك بـ {{ .Email }}.\r\n", + "inviteInvalid": "❗ رابط الدعوة هذا غير صالح أو تم استخدامه بالفعل.\r\n", + "inviteRateLimited": "⏳ محاولات دعوة كثيرة جدًا. يرجى المحاولة لاحقًا.\r\n", + "inviteRateLimitedAdmin": "⚠️ تم حظر محاولات الدعوة لـ {{ .User }} ({{ .ID }}): أكثر من {{ .Limit }} محاولات خلال ساعة. ربما يحاول أحدهم تخمين معرّفات الاشتراك.", + "inviteLink": "🔗 رابط الدعوة لـ {{ .Email }}:\r\n{{ .Link }}\r\n\r\nأول شخص يفتحه سيتم ربطه بهذا العميل.\r\n", "exhaustedCount": "🚨 عدد النفاذ لـ {{ .Type }}:\r\n", "onlinesCount": "🌐 العملاء الأونلاين: {{ .Count }}\r\n", "disabled": "🛑 معطل: {{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 سجل الـ IP", "ipLimit": "🔢 حد الـ IP", "setTGUser": "👤 ضبط مستخدم Telegram", + "inviteLink": "🔗 رابط الدعوة", "toggle": "🔘 تفعيل / تعطيل", "custom": "🔢 مخصص", "confirmNumber": "✅ تأكيد: {{ .Num }}", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index ab625c99c..53defe043 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -791,6 +791,7 @@ "password": "Password", "passwordDesc": "Used by Trojan, Shadowsocks, and TUIC clients; ignored for VLESS, VMess, Hysteria, and WireGuard.", "subId": "Subscription ID", + "subIdDesc": "Also works as this client's Telegram bot invite code: whoever sends it to the bot is linked to this client. Keep it long and random — a short or guessable ID can be claimed by someone else.", "online": "Online", "email": "Email", "emailInvalidChars": "Email cannot contain spaces, '/', '\\', or control characters", @@ -2468,6 +2469,11 @@ "download": "🔽 Download: ↓{{ .Download }}\r\n", "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 Telegram User: {{ .TelegramID }}\r\n", + "inviteBound": "✅ Your Telegram account is now linked to {{ .Email }}.\r\n", + "inviteInvalid": "❗ This invite link is invalid or has already been used.\r\n", + "inviteRateLimited": "⏳ Too many invite attempts. Please try again later.\r\n", + "inviteRateLimitedAdmin": "⚠️ Invite attempts blocked for {{ .User }} ({{ .ID }}): more than {{ .Limit }} tries in an hour. Someone may be guessing Subscription IDs.", + "inviteLink": "🔗 Invite link for {{ .Email }}:\r\n{{ .Link }}\r\n\r\nThe first person to open it is linked to this client.\r\n", "exhaustedCount": "🚨 Exhausted {{ .Type }} count:\r\n", "onlinesCount": "🌐 Online Clients: {{ .Count }}\r\n", "disabled": "🛑 Disabled: {{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 IP Log", "ipLimit": "🔢 IP Limit", "setTGUser": "👤 Set Telegram User", + "inviteLink": "🔗 Invite Link", "toggle": "🔘 Enable / Disable", "custom": "🔢 Custom", "confirmNumber": "✅ Confirm: {{ .Num }}", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index d38f4192b..0156a7648 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -791,6 +791,7 @@ "password": "Contraseña", "passwordDesc": "Solo la usan los clientes Trojan y Shadowsocks; se ignora para VLESS, VMess, Hysteria y WireGuard.", "subId": "ID de suscripción", + "subIdDesc": "También sirve como código de invitación de este cliente en el bot de Telegram: quien lo envíe al bot queda vinculado a este cliente. Mantenlo largo y aleatorio: un ID corto o fácil de adivinar puede ser reclamado por otra persona.", "online": "En línea", "email": "Email", "emailInvalidChars": "El correo no puede contener espacios, '/', '\\' ni caracteres de control", @@ -2468,6 +2469,11 @@ "download": "🔽 Descarga: ↓{{ .Download }}\r\n", "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 Usuario de Telegram: {{ .TelegramID }}\r\n", + "inviteBound": "✅ Tu cuenta de Telegram ahora está vinculada a {{ .Email }}.\r\n", + "inviteInvalid": "❗ Este enlace de invitación no es válido o ya se ha utilizado.\r\n", + "inviteRateLimited": "⏳ Demasiados intentos de invitación. Inténtalo más tarde.\r\n", + "inviteRateLimitedAdmin": "⚠️ Intentos de invitación bloqueados para {{ .User }} ({{ .ID }}): más de {{ .Limit }} intentos en una hora. Alguien podría estar adivinando IDs de suscripción.", + "inviteLink": "🔗 Enlace de invitación para {{ .Email }}:\r\n{{ .Link }}\r\n\r\nLa primera persona que lo abra quedará vinculada a este cliente.\r\n", "exhaustedCount": "🚨 Cantidad de Agotados {{ .Type }}:\r\n", "onlinesCount": "🌐 Clientes en línea: {{ .Count }}\r\n", "disabled": "🛑 Desactivado: {{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 Registro de IP", "ipLimit": "🔢 Límite de IP", "setTGUser": "👤 Establecer Usuario de Telegram", + "inviteLink": "🔗 Enlace de invitación", "toggle": "🔘 Habilitar / Deshabilitar", "custom": "🔢 Personalizado", "confirmNumber": "✅ Confirmar: {{ .Num }}", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 7527f7811..d95bcc7d7 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -791,6 +791,7 @@ "password": "رمز عبور", "passwordDesc": "فقط توسط کلاینت‌های Trojan و Shadowsocks استفاده می‌شود؛ برای VLESS، VMess، Hysteria و WireGuard نادیده گرفته می‌شود.", "subId": "شناسه اشتراک", + "subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل می‌شود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.", "online": "آنلاین", "email": "ایمیل", "emailInvalidChars": "ایمیل نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد", @@ -2468,6 +2469,11 @@ "download": "🔽 دانلود: ↓{{ .Download }}\r\n", "total": "📊 کل: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 کاربر تلگرام: {{ .TelegramID }}\r\n", + "inviteBound": "✅ حساب تلگرام شما به {{ .Email }} متصل شد.\r\n", + "inviteInvalid": "❗ این لینک دعوت نامعتبر است یا قبلاً استفاده شده است.\r\n", + "inviteRateLimited": "⏳ تعداد تلاش‌های دعوت بیش از حد مجاز است. لطفاً بعداً دوباره امتحان کنید.\r\n", + "inviteRateLimitedAdmin": "⚠️ تلاش‌های دعوت برای {{ .User }} ({{ .ID }}) مسدود شد: بیش از {{ .Limit }} تلاش در یک ساعت. ممکن است کسی در حال حدس زدن شناسه‌های اشتراک باشد.", + "inviteLink": "🔗 لینک دعوت برای {{ .Email }}:\r\n{{ .Link }}\r\n\r\nاولین کسی که آن را باز کند به این کاربر متصل می‌شود.\r\n", "exhaustedCount": "🚨 تعداد {{ .Type }} به‌اتمام‌رسیده‌است:\r\n", "onlinesCount": "🌐 کاربران‌آنلاین: {{ .Count }}\r\n", "disabled": "🛑 غیرفعال: {{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 لاگ آدرس‌های IP", "ipLimit": "🔢 محدودیت IP", "setTGUser": "👤 تنظیم کاربر تلگرام", + "inviteLink": "🔗 لینک دعوت", "toggle": "🔘 فعال / غیرفعال", "custom": "🔢 سفارشی", "confirmNumber": "✅ تایید: {{ .Num }}", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 4085cce0a..f93fee3f2 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -791,6 +791,7 @@ "password": "Kata sandi", "passwordDesc": "Hanya digunakan oleh klien Trojan dan Shadowsocks; diabaikan untuk VLESS, VMess, Hysteria, dan WireGuard.", "subId": "ID Langganan", + "subIdDesc": "Juga berfungsi sebagai kode undangan bot Telegram untuk klien ini: siapa pun yang mengirimkannya ke bot akan ditautkan ke klien ini. Buat panjang dan acak — ID yang pendek atau mudah ditebak bisa diklaim orang lain.", "online": "Online", "email": "Email", "emailInvalidChars": "Email tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol", @@ -2468,6 +2469,11 @@ "download": "🔽 Unduh: ↓{{ .Download }}\r\n", "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 Pengguna Telegram: {{ .TelegramID }}\r\n", + "inviteBound": "✅ Akun Telegram Anda kini tertaut ke {{ .Email }}.\r\n", + "inviteInvalid": "❗ Tautan undangan ini tidak valid atau sudah digunakan.\r\n", + "inviteRateLimited": "⏳ Terlalu banyak percobaan undangan. Silakan coba lagi nanti.\r\n", + "inviteRateLimitedAdmin": "⚠️ Percobaan undangan diblokir untuk {{ .User }} ({{ .ID }}): lebih dari {{ .Limit }} percobaan dalam satu jam. Seseorang mungkin sedang menebak ID langganan.", + "inviteLink": "🔗 Tautan undangan untuk {{ .Email }}:\r\n{{ .Link }}\r\n\r\nOrang pertama yang membukanya akan tertaut ke klien ini.\r\n", "exhaustedCount": "🚨 Jumlah Habis {{ .Type }}:\r\n", "onlinesCount": "🌐 Klien Online: {{ .Count }}\r\n", "disabled": "🛑 Dinonaktifkan: {{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 Log IP", "ipLimit": "🔢 Batas IP", "setTGUser": "👤 Set Pengguna Telegram", + "inviteLink": "🔗 Tautan Undangan", "toggle": "🔘 Aktifkan / Nonaktifkan", "custom": "🔢 Kustom", "confirmNumber": "✅ Konfirmasi: {{ .Num }}", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 5a5235a41..58be2e24f 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -791,6 +791,7 @@ "password": "パスワード", "passwordDesc": "Trojan と Shadowsocks のクライアントのみが使用します。VLESS、VMess、Hysteria、WireGuard では無視されます。", "subId": "サブスクリプション ID", + "subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。", "online": "オンライン", "email": "メール", "emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません", @@ -2468,6 +2469,11 @@ "download": "🔽 ダウンロード: ↓{{ .Download }}\r\n", "total": "📊 合計: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 Telegramユーザー:{{ .TelegramID }}\r\n", + "inviteBound": "✅ あなたの Telegram アカウントは {{ .Email }} にリンクされました。\r\n", + "inviteInvalid": "❗ この招待リンクは無効か、すでに使用されています。\r\n", + "inviteRateLimited": "⏳ 招待の試行回数が多すぎます。しばらくしてからもう一度お試しください。\r\n", + "inviteRateLimitedAdmin": "⚠️ {{ .User }} ({{ .ID }}) の招待の試行をブロックしました:1 時間に {{ .Limit }} 回を超えました。誰かがサブスクリプション ID を推測している可能性があります。", + "inviteLink": "🔗 {{ .Email }} の招待リンク:\r\n{{ .Link }}\r\n\r\n最初に開いた人がこのクライアントにリンクされます。\r\n", "exhaustedCount": "🚨 消耗済みの {{ .Type }} 数量:\r\n", "onlinesCount": "🌐 オンラインクライアント:{{ .Count }}\r\n", "disabled": "🛑 無効化:{{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 IPログ", "ipLimit": "🔢 IP制限", "setTGUser": "👤 Telegramユーザーを設定", + "inviteLink": "🔗 招待リンク", "toggle": "🔘 有効/無効", "custom": "🔢 カスタム", "confirmNumber": "✅ 確認: {{ .Num }}", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 3c959016b..b189680ef 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -791,6 +791,7 @@ "password": "Senha", "passwordDesc": "Usada apenas pelos clientes Trojan e Shadowsocks; ignorada para VLESS, VMess, Hysteria e WireGuard.", "subId": "ID da assinatura", + "subIdDesc": "Também funciona como código de convite deste cliente no bot do Telegram: quem enviá-lo ao bot fica vinculado a este cliente. Mantenha-o longo e aleatório — um ID curto ou fácil de adivinhar pode ser reivindicado por outra pessoa.", "online": "Online", "email": "Email", "emailInvalidChars": "O e-mail não pode conter espaços, '/', '\\' ou caracteres de controle", @@ -2468,6 +2469,11 @@ "download": "🔽 Download: ↓{{ .Download }}\r\n", "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 Usuário do Telegram: {{ .TelegramID }}\r\n", + "inviteBound": "✅ Sua conta do Telegram agora está vinculada a {{ .Email }}.\r\n", + "inviteInvalid": "❗ Este link de convite é inválido ou já foi utilizado.\r\n", + "inviteRateLimited": "⏳ Muitas tentativas de convite. Tente novamente mais tarde.\r\n", + "inviteRateLimitedAdmin": "⚠️ Tentativas de convite bloqueadas para {{ .User }} ({{ .ID }}): mais de {{ .Limit }} tentativas em uma hora. Alguém pode estar tentando adivinhar IDs de assinatura.", + "inviteLink": "🔗 Link de convite para {{ .Email }}:\r\n{{ .Link }}\r\n\r\nA primeira pessoa que abri-lo será vinculada a este cliente.\r\n", "exhaustedCount": "🚨 Contagem de {{ .Type }} esgotado:\r\n", "onlinesCount": "🌐 Clientes online: {{ .Count }}\r\n", "disabled": "🛑 Desativado: {{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 Log de IP", "ipLimit": "🔢 Limite de IP", "setTGUser": "👤 Definir usuário do Telegram", + "inviteLink": "🔗 Link de convite", "toggle": "🔘 Ativar / Desativar", "custom": "🔢 Personalizado", "confirmNumber": "✅ Confirmar: {{ .Num }}", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 00184c34e..7159df78f 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -791,6 +791,7 @@ "password": "Пароль", "passwordDesc": "Используется клиентами Trojan, Shadowsocks и TUIC; игнорируется для VLESS, VMess, Hysteria и WireGuard.", "subId": "ID подписки", + "subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.", "online": "В сети", "email": "Email", "emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы", @@ -2468,6 +2469,11 @@ "download": "🔽 Загрузка: ↓{{ .Download }}\r\n", "total": "📊 Всего: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 Telegram User ID: {{ .TelegramID }}\r\n", + "inviteBound": "✅ Ваш аккаунт Telegram привязан к {{ .Email }}.\r\n", + "inviteInvalid": "❗ Эта ссылка-приглашение недействительна или уже использована.\r\n", + "inviteRateLimited": "⏳ Слишком много попыток приглашения. Попробуйте позже.\r\n", + "inviteRateLimitedAdmin": "⚠️ Попытки приглашения заблокированы для {{ .User }} ({{ .ID }}): более {{ .Limit }} попыток за час. Возможно, кто-то подбирает ID подписок.", + "inviteLink": "🔗 Ссылка-приглашение для {{ .Email }}:\r\n{{ .Link }}\r\n\r\nПервый, кто её откроет, будет привязан к этому клиенту.\r\n", "exhaustedCount": "🚨 Количество исчерпанных {{ .Type }}:\r\n", "onlinesCount": "🌐 Клиентов онлайн: {{ .Count }}\r\n", "disabled": "🛑 Отключено: {{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 Лог IP", "ipLimit": "🔢 Лимит IP", "setTGUser": "👤 Установить пользователя Telegram", + "inviteLink": "🔗 Ссылка-приглашение", "toggle": "🔘 Вкл./Выкл.", "custom": "🔢 Своё", "confirmNumber": "✅ Подтвердить: {{ .Num }}", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index cce7a2ec2..a3d6d66f2 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -791,6 +791,7 @@ "password": "Şifre", "passwordDesc": "Yalnızca Trojan ve Shadowsocks istemcileri tarafından kullanılır; VLESS, VMess, Hysteria ve WireGuard için yok sayılır.", "subId": "Abonelik ID'si", + "subIdDesc": "Bu istemcinin Telegram botu davet kodu olarak da kullanılır: bunu bota gönderen kişi bu istemciye bağlanır. Uzun ve rastgele tutun — kısa veya tahmin edilebilir bir kimlik başkası tarafından sahiplenilebilir.", "online": "Çevrimiçi", "email": "E-posta", "emailInvalidChars": "E-posta boşluk, '/', '\\' veya kontrol karakterleri içeremez", @@ -2468,6 +2469,11 @@ "download": "🔽 İndirme: ↓{{ .Download }}\r\n", "total": "📊 Toplam: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 Telegram Kullanıcısı: {{ .TelegramID }}\r\n", + "inviteBound": "✅ Telegram hesabınız {{ .Email }} ile bağlandı.\r\n", + "inviteInvalid": "❗ Bu davet bağlantısı geçersiz veya daha önce kullanılmış.\r\n", + "inviteRateLimited": "⏳ Çok fazla davet denemesi. Lütfen daha sonra tekrar deneyin.\r\n", + "inviteRateLimitedAdmin": "⚠️ {{ .User }} ({{ .ID }}) için davet denemeleri engellendi: bir saatte {{ .Limit }} denemeden fazla. Birisi abonelik kimliklerini tahmin etmeye çalışıyor olabilir.", + "inviteLink": "🔗 {{ .Email }} için davet bağlantısı:\r\n{{ .Link }}\r\n\r\nBağlantıyı ilk açan kişi bu istemciye bağlanır.\r\n", "exhaustedCount": "🚨 Limiti Dolan {{ .Type }} sayısı:\r\n", "onlinesCount": "🌐 Çevrimiçi Kullanıcılar: {{ .Count }}\r\n", "disabled": "🛑 Devre Dışı: {{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 IP Günlüğü", "ipLimit": "🔢 IP Limiti", "setTGUser": "👤 Telegram Kullanıcısını Ayarla", + "inviteLink": "🔗 Davet Bağlantısı", "toggle": "🔘 Etkinleştir / Devre Dışı Bırak", "custom": "🔢 Özel", "confirmNumber": "✅ Onayla: {{ .Num }}", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 73885b8bc..8e9d70bc6 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -791,6 +791,7 @@ "password": "Пароль", "passwordDesc": "Використовується лише клієнтами Trojan і Shadowsocks; ігнорується для VLESS, VMess, Hysteria та WireGuard.", "subId": "ID підписки", + "subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.", "online": "У мережі", "email": "Email", "emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи", @@ -2468,6 +2469,11 @@ "download": "🔽 Завантаження: ↓{{ .Download }}\r\n", "total": "📊 Усього: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 Користувач Telegram: {{ .TelegramID }}\r\n", + "inviteBound": "✅ Ваш акаунт Telegram прив'язано до {{ .Email }}.\r\n", + "inviteInvalid": "❗ Це посилання-запрошення недійсне або вже використане.\r\n", + "inviteRateLimited": "⏳ Забагато спроб запрошення. Спробуйте пізніше.\r\n", + "inviteRateLimitedAdmin": "⚠️ Спроби запрошення заблоковано для {{ .User }} ({{ .ID }}): понад {{ .Limit }} спроб за годину. Можливо, хтось підбирає ID підписок.", + "inviteLink": "🔗 Посилання-запрошення для {{ .Email }}:\r\n{{ .Link }}\r\n\r\nПерший, хто його відкриє, буде прив'язаний до цього клієнта.\r\n", "exhaustedCount": "🚨 Вичерпано кількість {{ .Type }} count:\r\n", "onlinesCount": "🌐 Онлайн-клієнти: {{ .Count }}\r\n", "disabled": "🛑 Вимкнено: {{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 IP журнал", "ipLimit": "🔢 IP Ліміт", "setTGUser": "👤 Встановити користувача Telegram", + "inviteLink": "🔗 Посилання-запрошення", "toggle": "🔘 Увімкнути / Вимкнути", "custom": "🔢 Своє", "confirmNumber": "✅ Підтвердити: {{ .Num }}", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 5651c8ffb..0747ddafb 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -791,6 +791,7 @@ "password": "Mật khẩu", "passwordDesc": "Chỉ được dùng bởi các client Trojan và Shadowsocks; bị bỏ qua đối với VLESS, VMess, Hysteria và WireGuard.", "subId": "ID đăng ký", + "subIdDesc": "Cũng được dùng làm mã mời bot Telegram của client này: ai gửi mã này cho bot sẽ được liên kết với client. Hãy để mã dài và ngẫu nhiên — ID ngắn hoặc dễ đoán có thể bị người khác chiếm.", "online": "Trực tuyến", "email": "Email", "emailInvalidChars": "Email không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển", @@ -2468,6 +2469,11 @@ "download": "🔽 Tải xuống: ↓{{ .Download }}\r\n", "total": "📊 Tổng: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 Người dùng Telegram: {{ .TelegramID }}\r\n", + "inviteBound": "✅ Tài khoản Telegram của bạn đã được liên kết với {{ .Email }}.\r\n", + "inviteInvalid": "❗ Liên kết mời này không hợp lệ hoặc đã được sử dụng.\r\n", + "inviteRateLimited": "⏳ Quá nhiều lần thử lời mời. Vui lòng thử lại sau.\r\n", + "inviteRateLimitedAdmin": "⚠️ Đã chặn các lần thử lời mời của {{ .User }} ({{ .ID }}): hơn {{ .Limit }} lần trong một giờ. Có thể ai đó đang đoán ID đăng ký.", + "inviteLink": "🔗 Liên kết mời cho {{ .Email }}:\r\n{{ .Link }}\r\n\r\nNgười đầu tiên mở nó sẽ được liên kết với client này.\r\n", "exhaustedCount": "🚨 Số lần cạn kiệt {{ .Type }}:\r\n", "onlinesCount": "🌐 Khách hàng trực tuyến: {{ .Count }}\r\n", "disabled": "🛑 Vô hiệu hóa: {{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 Nhật ký địa chỉ IP", "ipLimit": "🔢 Giới Hạn địa chỉ IP", "setTGUser": "👤 Đặt Người Dùng Telegram", + "inviteLink": "🔗 Liên kết mời", "toggle": "🔘 Bật / Tắt", "custom": "🔢 Tùy chỉnh", "confirmNumber": "✅ Xác nhận: {{ .Num }}", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 30350683b..7477dffa7 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -791,6 +791,7 @@ "password": "密码", "passwordDesc": "用于 Trojan、Shadowsocks 和 TUIC 客户端;对 VLESS、VMess、Hysteria 和 WireGuard 忽略。", "subId": "订阅 ID", + "subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。", "online": "在线", "email": "邮箱", "emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符", @@ -2468,6 +2469,11 @@ "download": "🔽 下载: ↓{{ .Download }}\r\n", "total": "📊 总计: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 电报用户:{{ .TelegramID }}\r\n", + "inviteBound": "✅ 您的 Telegram 账号已绑定到 {{ .Email }}。\r\n", + "inviteInvalid": "❗ 此邀请链接无效或已被使用。\r\n", + "inviteRateLimited": "⏳ 邀请尝试次数过多,请稍后再试。\r\n", + "inviteRateLimitedAdmin": "⚠️ 已阻止 {{ .User }}({{ .ID }})的邀请尝试:一小时内超过 {{ .Limit }} 次。可能有人在猜测订阅 ID。", + "inviteLink": "🔗 {{ .Email }} 的邀请链接:\r\n{{ .Link }}\r\n\r\n第一个打开它的人将绑定到此客户端。\r\n", "exhaustedCount": "🚨 耗尽的 {{ .Type }} 数量:\r\n", "onlinesCount": "🌐 在线客户:{{ .Count }}\r\n", "disabled": "🛑 禁用:{{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 IP 日志", "ipLimit": "🔢 IP 限制", "setTGUser": "👤 设置 Telegram 用户", + "inviteLink": "🔗 邀请链接", "toggle": "🔘 启用/禁用", "custom": "🔢 自定义", "confirmNumber": "✅ 确认: {{ .Num }}", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 97bce6712..a98aa03fe 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -791,6 +791,7 @@ "password": "密碼", "passwordDesc": "僅 Trojan 與 Shadowsocks 用戶端使用;VLESS、VMess、Hysteria 和 WireGuard 會忽略此項。", "subId": "訂閱 ID", + "subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。", "online": "上線", "email": "電子郵件", "emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元", @@ -2468,6 +2469,11 @@ "download": "🔽 下載: ↓{{ .Download }}\r\n", "total": "📊 總計: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", "TGUser": "👤 電報使用者:{{ .TelegramID }}\r\n", + "inviteBound": "✅ 您的 Telegram 帳號已綁定至 {{ .Email }}。\r\n", + "inviteInvalid": "❗ 此邀請連結無效或已被使用。\r\n", + "inviteRateLimited": "⏳ 邀請嘗試次數過多,請稍後再試。\r\n", + "inviteRateLimitedAdmin": "⚠️ 已封鎖 {{ .User }}({{ .ID }})的邀請嘗試:一小時內超過 {{ .Limit }} 次。可能有人在猜測訂閱 ID。", + "inviteLink": "🔗 {{ .Email }} 的邀請連結:\r\n{{ .Link }}\r\n\r\n第一個開啟它的人將綁定至此客戶端。\r\n", "exhaustedCount": "🚨 耗盡的 {{ .Type }} 數量:\r\n", "onlinesCount": "🌐 線上客戶:{{ .Count }}\r\n", "disabled": "🛑 禁用:{{ .Disabled }}\r\n", @@ -2535,6 +2541,7 @@ "ipLog": "🔢 IP 日誌", "ipLimit": "🔢 IP 限制", "setTGUser": "👤 設定 Telegram 使用者", + "inviteLink": "🔗 邀請連結", "toggle": "🔘 啟用/禁用", "custom": "🔢 自訂", "confirmNumber": "✅ 確認: {{ .Num }}",