소스 검색

feat(tgbot): access levels and /start account binding (#6518)

* feat(tgbot): gate the bot behind three user levels

Every Telegram account that found the bot could run /help, /status and
/usage, and tap any client button it could forge: nothing separated an
account no admin had bound from a customer.

Each update now resolves to stranger, client or admin, and commands are
allowlisted per level so a command added later stays admin-only until it
is listed. A stranger may run /start and /id only, and /start answers with
the ChatID an admin needs to bind it; a stranger's callbacks are answered
and dropped. Client detection reads the same tgId lookup as
clientOwnedByTgUser, so the level gate and the ownership check agree.

The bot also ignores everything outside private chats: authorization keys
on the sender while wizard state keys on the chat, and the two are the
same identity only in a private chat.

* feat(tgbot): bind Telegram accounts through /start deep links

Linking a customer meant the customer sending /id and an admin copying
the ChatID into the client by hand, which does not scale past a few
customers and is easy to get wrong.

The admin client card now offers an invite link, t.me/<bot>?start=<subId>,
and the first account to open it is bound through the existing
SetClientTelegramUserID. A subId already grants the subscription, so
binding gives the holder nothing the token did not. A subscription that
spans several clients binds all of them, and is refused if any part
belongs to another account; re-opening your own link is idempotent.
Unknown and already-claimed tokens share one reply, so the link cannot
be used to probe for valid subIds.

* fix(tgbot): harden invite claims after review

Review of the access-level and binding change found five problems:

- Concurrent claims of one link all read the client as unbound, all bound
  and all were told so, while only the last write held. Resolving and
  binding now share one lock, and a bind that fails part-way through a
  multi-client subscription undoes the bindings it already made.
- A subId has no minimum strength and the bot needs only its public
  username, so /start was an unthrottled guessing oracle. Non-admin claim
  attempts are capped at five per account per hour, the first refused one
  notifies the admins, and the Subscription ID field now says it doubles
  as the bot invite code.
- levelOf expanded every inbound's client JSON on every non-admin update.
  It now reads the indexed tg_id column of the clients table.
- A button tapped in a group chat was dropped unanswered and kept
  spinning, with nothing logged. It is answered now, and each ignored chat
  is logged once.
- The subId was pasted raw into the t.me link, so '#' or '&' truncated it
  and Telegram rejects anything outside A-Za-z0-9_-. The payload is now
  base64url, and a subId too long for the 64-character limit is refused.

* fix(tgbot): answer group chats again and make the claim race test bite

ignoredChat dropped every non-private chat because wizard state was
keyed by chat while authorization keyed on the sender. #6604 on main
re-keyed that state by (chat, user) so admins can drive the bot from a
group, so after the merge the drop only took the whole bot away from
those admins, report keyboards sent to a group included. The level gate
already keys on the sender, so group chats need no special case.

TestConcurrentClaimsBindOnlyOneAccount passed with inviteClaimMu
removed: the first claimant took the pool's idle connection and bound
before the rest had opened theirs, so no two ever raced. It now holds
the inbound write the binds need until every claimant has resolved,
and fails without the lock ("6 accounts told they bound").

TestCommandAllowed restated the commandsByLevel map; TestGateCommand
drives the same allowlist through gateCommand. TestIgnoredChat goes
with the code it pinned.

* docs(tgbot): document access levels and invite links

The command table still said /help and /status answer anyone. An
account no admin has linked now reaches only /start and /id, and a
customer is linked through the client card's Invite Link, whose token
is the Subscription ID. Updated in en, fa, ru and zh.

---------

Co-authored-by: MHSanaei <[email protected]>
pcxzs 9 시간 전
부모
커밋
7aa5fc085f

+ 11 - 2
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 <arg>`     | both   | Admins search clients; users look up their own usage         |
 | `/inbound <remark>`| 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

+ 11 - 2
docs/content/docs/fa/operations/telegram-bot.mdx

@@ -53,13 +53,22 @@ icon: Send
 
 | فرمان              | چه کسی | عملکرد                                                       |
 | ------------------ | ------ | ------------------------------------------------------------ |
-| `/start`، `/help`  | همه    | پیام خوش‌آمدگویی و منوی دکمه‌های درون‌خطی                      |
-| `/status`          | همه    | تأیید فعال بودن ربات                                          |
+| `/start`           | همه    | پیام خوش‌آمدگویی و منوی دکمه‌های درون‌خطی؛ حساب متصل‌نشده فقط شناسه‌ی Telegram خود را می‌گیرد |
+| `/help`            | هر دو  | منوی دکمه‌های درون‌خطی                                         |
+| `/status`          | هر دو  | تأیید فعال بودن ربات                                          |
 | `/id`              | همه    | نمایش شناسه‌ی عددی Telegram شما                               |
 | `/usage <arg>`     | هر دو  | ادمین‌ها کلاینت‌ها را جست‌وجو می‌کنند؛ کاربران مصرف خود را می‌بینند |
 | `/inbound <remark>`| ادمین  | نمایش جزئیات یک ورودی                                         |
 | `/restart`         | ادمین  | راه‌اندازی مجدد Xray                                          |
 
+کاربر یعنی حساب Telegramی که دست‌کم به یک کلاینت متصل است. هر حساب دیگری فقط
+`/start` و `/id` را می‌تواند اجرا کند و ربات فرمان‌ها و دکمه‌های دیگر آن را نادیده
+می‌گیرد. برای اتصال یک مشتری، در کارت کلاینت در ربات روی **لینک دعوت** بزنید و لینک `t.me`
+را برایش بفرستید: نخستین حسابی که آن را باز کند به همه‌ی کلاینت‌هایی که آن شناسه
+اشتراک را دارند متصل می‌شود. شناسه اشتراک همان کد دعوت است، پس آن را طولانی و
+تصادفی نگه دارید. هر حساب در هر ساعت پنج بار می‌تواند تلاش کند و پس از آن به
+ادمین‌ها اطلاع داده می‌شود.
+
 ادمین‌ها همچنین جریان‌های دکمه‌ی درون‌خطی برای مصرف سرور، گزارش‌های ترافیک مرتب‌شده،
 بازنشانی ترافیک، پشتیبان‌گیری از DB، گزارش‌های مسدودسازی، فهرست کردن ورودی‌ها/کلاینت‌ها،
 کلاینت‌های آنلاین، «به‌زودی تمام‌شونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند.

+ 12 - 2
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 <arg>`     | обоим  | Администраторы ищут клиентов; пользователи смотрят свой расход |
 | `/inbound <remark>`| админ  | Показывает сведения о входящем подключении                   |
 | `/restart`         | админ  | Перезапускает Xray                                          |
 
+Пользователь — это аккаунт Telegram, привязанный хотя бы к одному клиенту. Любой
+другой аккаунт может выполнять только `/start` и `/id`; остальные его команды и
+нажатия кнопок бот игнорирует. Чтобы привязать клиента, нажмите
+**Ссылка-приглашение** в карточке клиента в боте и отправьте ему ссылку `t.me`: первый
+открывший её аккаунт привязывается ко всем клиентам с этим ID подписки. ID
+подписки служит кодом приглашения, поэтому делайте его длинным и случайным.
+У каждого аккаунта пять попыток в час, после чего администраторы получают
+уведомление.
+
 Администраторам также доступны сценарии со встроенными кнопками: использование
 сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД,
 журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты,

+ 9 - 2
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 <arg>`     | 两者   | 管理员可搜索客户端;用户则查询自己的用量                     |
 | `/inbound <remark>`| 管理员 | 显示某个入站的详情                                           |
 | `/restart`         | 管理员 | 重启 Xray                                                    |
 
+用户是指至少绑定了一个客户端的 Telegram 账号。其他账号只能使用 `/start` 和
+`/id`,机器人会忽略它们的其他命令和按钮点击。要绑定客户,请在机器人的客户端卡片上点击
+**邀请链接**,并把 `t.me` 链接发给对方:第一个打开该链接的账号会绑定到共用该订阅
+ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长且随机。每个账号每小时
+可尝试五次,用完后会通知管理员。
+
 管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、
 重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、
 “即将耗尽”,以及完整的**添加客户端**向导。普通用户则可以使用按钮查看

+ 4 - 1
frontend/src/pages/clients/ClientFormModal.tsx

@@ -1151,7 +1151,10 @@ export default function ClientFormModal({
                         </Space.Compact>
                       </Form.Item>
 
-                      <Form.Item label={t('pages.clients.subId')}>
+                      <Form.Item
+                        label={t('pages.clients.subId')}
+                        tooltip={t('pages.clients.subIdDesc')}
+                      >
                         <Space.Compact style={{ display: 'flex' }}>
                           <Input
                             value={subId}

+ 11 - 0
internal/web/service/client_lookup.go

@@ -104,6 +104,17 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int,
 	return ids, nil
 }
 
+// sub_id carries a plain index, not a unique one: one subscription can cover
+// several clients, so callers acting on a subId must handle all of them.
+func (s *ClientService) GetRecordsBySubID(subId string) ([]*model.ClientRecord, error) {
+	if subId == "" {
+		return nil, errors.New("sub_id must not be empty")
+	}
+	var rows []*model.ClientRecord
+	err := database.GetDB().Where("sub_id = ?", subId).Order("id ASC").Find(&rows).Error
+	return rows, err
+}
+
 func (s *ClientService) GetRecordsByTgID(tgId int64) ([]*model.ClientRecord, error) {
 	if tgId <= 0 {
 		return nil, errors.New("tg_id must be a positive integer")

+ 1 - 0
internal/web/service/tgbot/tgbot_client.go

@@ -745,6 +745,7 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
 		),
 		tu.InlineKeyboardRow(
 			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData(t.encodeQuery("tg_user "+email)),
+			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.inviteLink")).WithCallbackData(t.encodeQuery("client_invite_link "+email)),
 		),
 		tu.InlineKeyboardRow(
 			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.toggle")).WithCallbackData(t.encodeQuery("toggle_enable "+email)),

+ 237 - 0
internal/web/service/tgbot/tgbot_invite.go

@@ -0,0 +1,237 @@
+package tgbot
+
+import (
+	"encoding/base64"
+	"html"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+
+	"github.com/mymmrac/telego"
+)
+
+type inviteOutcome int
+
+const (
+	inviteInvalid inviteOutcome = iota
+	inviteTaken
+	inviteAlreadyOwned
+	inviteBindable
+)
+
+// A client's SubID doubles as its invite token: whoever holds it can already
+// fetch the subscription, so binding grants no access the token did not.
+func (t *Tgbot) resolveInviteToken(token string, fromID int64) (inviteOutcome, []*model.ClientRecord) {
+	token = strings.TrimSpace(token)
+	if token == "" || fromID <= 0 {
+		return inviteInvalid, nil
+	}
+	records, err := t.clientService.GetRecordsBySubID(token)
+	if err != nil || len(records) == 0 {
+		return inviteInvalid, nil
+	}
+	return classifyInvite(records, fromID), records
+}
+
+// One subscription can span several clients, so a token is claimable only when
+// no part of it belongs to someone else.
+func classifyInvite(records []*model.ClientRecord, fromID int64) inviteOutcome {
+	unbound := false
+	for _, record := range records {
+		switch record.TgID {
+		case 0:
+			unbound = true
+		case fromID:
+		default:
+			return inviteTaken
+		}
+	}
+	if unbound {
+		return inviteBindable
+	}
+	return inviteAlreadyOwned
+}
+
+// Claims run on concurrent handlers, so resolving and binding happen under one
+// lock: a second claimant must see the first one's binding, not the rows it read.
+var inviteClaimMu sync.Mutex
+
+// claimInvite reports the outcome it told the user, bindErr aside, so a caller
+// can tell a bind that landed from a refusal without reading the reply.
+func (t *Tgbot) claimInvite(chatId int64, fromID int64, payload string) inviteOutcome {
+	token, ok := decodeInvitePayload(payload)
+	if !ok {
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteInvalid"))
+		return inviteInvalid
+	}
+
+	inviteClaimMu.Lock()
+	outcome, records := t.resolveInviteToken(token, fromID)
+	var bindErr error
+	if outcome == inviteBindable {
+		bindErr = t.bindRecordsToUser(records, fromID)
+	}
+	inviteClaimMu.Unlock()
+
+	switch outcome {
+	case inviteAlreadyOwned:
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteBound", "Email=="+recordEmails(records)))
+	case inviteBindable:
+		if bindErr != nil {
+			logger.Warning("tgbot: invite bind failed:", bindErr)
+			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
+			return inviteInvalid
+		}
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteBound", "Email=="+recordEmails(records)))
+	default:
+		// Unknown and already-claimed tokens share one reply, so a prober cannot
+		// tell a valid SubID from an invalid one.
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteInvalid"))
+	}
+	return outcome
+}
+
+func recordEmails(records []*model.ClientRecord) string {
+	emails := make([]string, 0, len(records))
+	for _, record := range records {
+		emails = append(emails, record.Email)
+	}
+	return strings.Join(emails, ", ")
+}
+
+// Every unbound client behind the token is bound, so a subscription spanning
+// several inbounds does not leave the customer holding only part of it. A failure
+// part-way undoes this claim's bindings, so the reply never hides a half-bind.
+func (t *Tgbot) bindRecordsToUser(records []*model.ClientRecord, tgID int64) error {
+	var bound []int
+	for _, record := range records {
+		if record.TgID != 0 {
+			continue
+		}
+		traffic, err := t.inboundService.GetClientTrafficByEmail(record.Email)
+		if err == nil && traffic == nil {
+			err = common.NewError("no traffic record for client:", record.Email)
+		}
+		if err == nil {
+			err = t.setClientTgID(traffic.Id, tgID)
+		}
+		if err != nil {
+			for _, trafficID := range bound {
+				if undoErr := t.setClientTgID(trafficID, EmptyTelegramUserID); undoErr != nil {
+					logger.Warning("tgbot: undoing partial invite bind failed:", undoErr)
+				}
+			}
+			return err
+		}
+		bound = append(bound, traffic.Id)
+	}
+	return nil
+}
+
+func (t *Tgbot) setClientTgID(trafficID int, tgID int64) error {
+	needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, trafficID, tgID)
+	if needRestart {
+		t.xrayService.SetToNeedRestart()
+	}
+	return err
+}
+
+// Telegram accepts only A-Za-z0-9_- in a start payload, at most 64 characters,
+// while a subId may hold '#', '&' or non-ASCII; base64url carries any subId
+// that fits intact instead of letting the link truncate it into another one.
+const maxInvitePayload = 64
+
+func encodeInvitePayload(subID string) (string, bool) {
+	payload := base64.RawURLEncoding.EncodeToString([]byte(subID))
+	return payload, len(payload) <= maxInvitePayload
+}
+
+func decodeInvitePayload(payload string) (string, bool) {
+	raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(payload))
+	if err != nil || len(raw) == 0 {
+		return "", false
+	}
+	return string(raw), true
+}
+
+func (t *Tgbot) sendInviteLink(chatId int64, email string) {
+	record, err := t.clientService.GetRecordByEmail(nil, email)
+	username := botUsername()
+	if err != nil || record.SubID == "" || username == "" {
+		logger.Warning("tgbot: invite link unavailable for", email, err)
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
+		return
+	}
+	payload, ok := encodeInvitePayload(record.SubID)
+	if !ok {
+		logger.Warning("tgbot: subId of", email, "is too long for a Telegram invite link")
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
+		return
+	}
+	link := "https://t.me/" + username + "?start=" + payload
+	t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteLink", "Email=="+email, "Link=="+link))
+}
+
+// A subId can be short or human-readable, so claim attempts are capped per
+// Telegram account: guessing stays slow, and admins hear about whoever tries.
+const (
+	inviteAttemptLimit  = 5
+	inviteAttemptWindow = time.Hour
+)
+
+type inviteAttempts struct {
+	windowStart time.Time
+	count       int
+}
+
+var (
+	inviteAttemptsMu  sync.Mutex
+	inviteAttemptsBy  = map[int64]*inviteAttempts{}
+	inviteAttemptsNow = time.Now
+)
+
+// allowInviteAttempt counts one claim attempt and reports whether it may run.
+// Admins are told once per window, on the first attempt past the limit.
+func (t *Tgbot) allowInviteAttempt(from *telego.User) bool {
+	now := inviteAttemptsNow()
+	inviteAttemptsMu.Lock()
+	for id, a := range inviteAttemptsBy {
+		if now.Sub(a.windowStart) >= 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 := `<a href="tg://user?id=` + id + `">` + html.EscapeString(name) + `</a>`
+	if from.Username != "" {
+		mention += " @" + html.EscapeString(from.Username)
+	}
+	return mention
+}

+ 221 - 0
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: "<b>x</b>"}
+	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: "<b>Eve</b>", Username: "eve"})
+	want := `<a href="tg://user?id=42">&lt;b&gt;Eve&lt;/b&gt;</a> @eve`
+	if got != want {
+		t.Errorf("tgUserMention = %q, want %q", got, want)
+	}
+}

+ 72 - 0
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
+}

+ 100 - 0
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)
+	}
+}

+ 21 - 2
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(

+ 7 - 0
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": "✅ تم ربط حساب تيليجرام الخاص بك بـ <code>{{ .Email }}</code>.\r\n",
+      "inviteInvalid": "❗ رابط الدعوة هذا غير صالح أو تم استخدامه بالفعل.\r\n",
+      "inviteRateLimited": "⏳ محاولات دعوة كثيرة جدًا. يرجى المحاولة لاحقًا.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ تم حظر محاولات الدعوة لـ {{ .User }} (<code>{{ .ID }}</code>): أكثر من {{ .Limit }} محاولات خلال ساعة. ربما يحاول أحدهم تخمين معرّفات الاشتراك.",
+      "inviteLink": "🔗 رابط الدعوة لـ <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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 <code>{{ .Email }}</code>.\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 }} (<code>{{ .ID }}</code>): more than {{ .Limit }} tries in an hour. Someone may be guessing Subscription IDs.",
+      "inviteLink": "🔗 Invite link for <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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 <code>{{ .Email }}</code>.\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 }} (<code>{{ .ID }}</code>): más de {{ .Limit }} intentos en una hora. Alguien podría estar adivinando IDs de suscripción.",
+      "inviteLink": "🔗 Enlace de invitación para <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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": "✅ حساب تلگرام شما به <code>{{ .Email }}</code> متصل شد.\r\n",
+      "inviteInvalid": "❗ این لینک دعوت نامعتبر است یا قبلاً استفاده شده است.\r\n",
+      "inviteRateLimited": "⏳ تعداد تلاش‌های دعوت بیش از حد مجاز است. لطفاً بعداً دوباره امتحان کنید.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ تلاش‌های دعوت برای {{ .User }} (<code>{{ .ID }}</code>) مسدود شد: بیش از {{ .Limit }} تلاش در یک ساعت. ممکن است کسی در حال حدس زدن شناسه‌های اشتراک باشد.",
+      "inviteLink": "🔗 لینک دعوت برای <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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 <code>{{ .Email }}</code>.\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 }} (<code>{{ .ID }}</code>): lebih dari {{ .Limit }} percobaan dalam satu jam. Seseorang mungkin sedang menebak ID langganan.",
+      "inviteLink": "🔗 Tautan undangan untuk <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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 アカウントは <code>{{ .Email }}</code> にリンクされました。\r\n",
+      "inviteInvalid": "❗ この招待リンクは無効か、すでに使用されています。\r\n",
+      "inviteRateLimited": "⏳ 招待の試行回数が多すぎます。しばらくしてからもう一度お試しください。\r\n",
+      "inviteRateLimitedAdmin": "⚠️ {{ .User }} (<code>{{ .ID }}</code>) の招待の試行をブロックしました:1 時間に {{ .Limit }} 回を超えました。誰かがサブスクリプション ID を推測している可能性があります。",
+      "inviteLink": "🔗 <code>{{ .Email }}</code> の招待リンク:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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 <code>{{ .Email }}</code>.\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 }} (<code>{{ .ID }}</code>): mais de {{ .Limit }} tentativas em uma hora. Alguém pode estar tentando adivinhar IDs de assinatura.",
+      "inviteLink": "🔗 Link de convite para <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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 привязан к <code>{{ .Email }}</code>.\r\n",
+      "inviteInvalid": "❗ Эта ссылка-приглашение недействительна или уже использована.\r\n",
+      "inviteRateLimited": "⏳ Слишком много попыток приглашения. Попробуйте позже.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ Попытки приглашения заблокированы для {{ .User }} (<code>{{ .ID }}</code>): более {{ .Limit }} попыток за час. Возможно, кто-то подбирает ID подписок.",
+      "inviteLink": "🔗 Ссылка-приглашение для <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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 <code>{{ .Email }}</code> 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 }} (<code>{{ .ID }}</code>) için davet denemeleri engellendi: bir saatte {{ .Limit }} denemeden fazla. Birisi abonelik kimliklerini tahmin etmeye çalışıyor olabilir.",
+      "inviteLink": "🔗 <code>{{ .Email }}</code> için davet bağlantısı:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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 прив'язано до <code>{{ .Email }}</code>.\r\n",
+      "inviteInvalid": "❗ Це посилання-запрошення недійсне або вже використане.\r\n",
+      "inviteRateLimited": "⏳ Забагато спроб запрошення. Спробуйте пізніше.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ Спроби запрошення заблоковано для {{ .User }} (<code>{{ .ID }}</code>): понад {{ .Limit }} спроб за годину. Можливо, хтось підбирає ID підписок.",
+      "inviteLink": "🔗 Посилання-запрошення для <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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 <code>{{ .Email }}</code>.\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 }} (<code>{{ .ID }}</code>): 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 <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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 账号已绑定到 <code>{{ .Email }}</code>。\r\n",
+      "inviteInvalid": "❗ 此邀请链接无效或已被使用。\r\n",
+      "inviteRateLimited": "⏳ 邀请尝试次数过多,请稍后再试。\r\n",
+      "inviteRateLimitedAdmin": "⚠️ 已阻止 {{ .User }}(<code>{{ .ID }}</code>)的邀请尝试:一小时内超过 {{ .Limit }} 次。可能有人在猜测订阅 ID。",
+      "inviteLink": "🔗 <code>{{ .Email }}</code> 的邀请链接:\r\n<code>{{ .Link }}</code>\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 }}",

+ 7 - 0
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 帳號已綁定至 <code>{{ .Email }}</code>。\r\n",
+      "inviteInvalid": "❗ 此邀請連結無效或已被使用。\r\n",
+      "inviteRateLimited": "⏳ 邀請嘗試次數過多,請稍後再試。\r\n",
+      "inviteRateLimitedAdmin": "⚠️ 已封鎖 {{ .User }}(<code>{{ .ID }}</code>)的邀請嘗試:一小時內超過 {{ .Limit }} 次。可能有人在猜測訂閱 ID。",
+      "inviteLink": "🔗 <code>{{ .Email }}</code> 的邀請連結:\r\n<code>{{ .Link }}</code>\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 }}",