mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 15:25:01 +03:00
15fee2a035
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.
220 lines
6.4 KiB
Go
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
|
|
}
|