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 := `` + html.EscapeString(name) + ``
if from.Username != "" {
mention += " @" + html.EscapeString(from.Username)
}
return mention
}