Files
3x-ui/internal/web/service/tgbot/tgbot_invite.go
T
DIMFLIX 15fee2a035 feat(tgbot): rebuild the Telegram bot around editable screens and inline lists (#6760)
The bot answered every action with a new message, so a chat filled with stale
menus whose buttons still acted, and long client or inbound lists were paged
through dozens of buttons. Each chat now has one live screen that is edited in
place: the menu is grouped into Server, Clients, Traffic and Maintenance, lists
open through inline search, the add-client flow starts from a draft card, QR
codes arrive as one labelled sheet, and repeated notices collapse into one with
a counter. A client's /start and /usage show only that client's own links and
usage, groups are refused, and the settings page warns when the bot's inline
mode is off. Admin topics, the indexed Telegram lookup and every locale,
Slovak included, carry over.
2026-10-24 12:39:56 +02:00

220 lines
6.4 KiB
Go

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
}
// 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
}