tgbot_invite.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. package tgbot
  2. import (
  3. "encoding/base64"
  4. "html"
  5. "strconv"
  6. "strings"
  7. "sync"
  8. "time"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  10. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  11. "github.com/mhsanaei/3x-ui/v3/internal/util/common"
  12. "github.com/mymmrac/telego"
  13. )
  14. type inviteOutcome int
  15. const (
  16. inviteInvalid inviteOutcome = iota
  17. inviteTaken
  18. inviteAlreadyOwned
  19. inviteBindable
  20. )
  21. // A client's SubID doubles as its invite token: whoever holds it can already
  22. // fetch the subscription, so binding grants no access the token did not.
  23. func (t *Tgbot) resolveInviteToken(token string, fromID int64) (inviteOutcome, []*model.ClientRecord) {
  24. token = strings.TrimSpace(token)
  25. if token == "" || fromID <= 0 {
  26. return inviteInvalid, nil
  27. }
  28. records, err := t.clientService.GetRecordsBySubID(token)
  29. if err != nil || len(records) == 0 {
  30. return inviteInvalid, nil
  31. }
  32. return classifyInvite(records, fromID), records
  33. }
  34. // One subscription can span several clients, so a token is claimable only when
  35. // no part of it belongs to someone else.
  36. func classifyInvite(records []*model.ClientRecord, fromID int64) inviteOutcome {
  37. unbound := false
  38. for _, record := range records {
  39. switch record.TgID {
  40. case 0:
  41. unbound = true
  42. case fromID:
  43. default:
  44. return inviteTaken
  45. }
  46. }
  47. if unbound {
  48. return inviteBindable
  49. }
  50. return inviteAlreadyOwned
  51. }
  52. // Claims run on concurrent handlers, so resolving and binding happen under one
  53. // lock: a second claimant must see the first one's binding, not the rows it read.
  54. var inviteClaimMu sync.Mutex
  55. // claimInvite reports the outcome it told the user, bindErr aside, so a caller
  56. // can tell a bind that landed from a refusal without reading the reply.
  57. func (t *Tgbot) claimInvite(chatId int64, fromID int64, payload string) inviteOutcome {
  58. token, ok := decodeInvitePayload(payload)
  59. if !ok {
  60. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteInvalid"))
  61. return inviteInvalid
  62. }
  63. inviteClaimMu.Lock()
  64. outcome, records := t.resolveInviteToken(token, fromID)
  65. var bindErr error
  66. if outcome == inviteBindable {
  67. bindErr = t.bindRecordsToUser(records, fromID)
  68. }
  69. inviteClaimMu.Unlock()
  70. switch outcome {
  71. case inviteAlreadyOwned:
  72. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteBound", "Email=="+recordEmails(records)))
  73. case inviteBindable:
  74. if bindErr != nil {
  75. logger.Warning("tgbot: invite bind failed:", bindErr)
  76. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
  77. return inviteInvalid
  78. }
  79. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteBound", "Email=="+recordEmails(records)))
  80. default:
  81. // Unknown and already-claimed tokens share one reply, so a prober cannot
  82. // tell a valid SubID from an invalid one.
  83. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteInvalid"))
  84. }
  85. return outcome
  86. }
  87. func recordEmails(records []*model.ClientRecord) string {
  88. emails := make([]string, 0, len(records))
  89. for _, record := range records {
  90. emails = append(emails, record.Email)
  91. }
  92. return strings.Join(emails, ", ")
  93. }
  94. // Every unbound client behind the token is bound, so a subscription spanning
  95. // several inbounds does not leave the customer holding only part of it. A failure
  96. // part-way undoes this claim's bindings, so the reply never hides a half-bind.
  97. func (t *Tgbot) bindRecordsToUser(records []*model.ClientRecord, tgID int64) error {
  98. var bound []int
  99. for _, record := range records {
  100. if record.TgID != 0 {
  101. continue
  102. }
  103. traffic, err := t.inboundService.GetClientTrafficByEmail(record.Email)
  104. if err == nil && traffic == nil {
  105. err = common.NewError("no traffic record for client:", record.Email)
  106. }
  107. if err == nil {
  108. err = t.setClientTgID(traffic.Id, tgID)
  109. }
  110. if err != nil {
  111. for _, trafficID := range bound {
  112. if undoErr := t.setClientTgID(trafficID, EmptyTelegramUserID); undoErr != nil {
  113. logger.Warning("tgbot: undoing partial invite bind failed:", undoErr)
  114. }
  115. }
  116. return err
  117. }
  118. bound = append(bound, traffic.Id)
  119. }
  120. return nil
  121. }
  122. func (t *Tgbot) setClientTgID(trafficID int, tgID int64) error {
  123. needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, trafficID, tgID)
  124. if needRestart {
  125. t.xrayService.SetToNeedRestart()
  126. }
  127. return err
  128. }
  129. // Telegram accepts only A-Za-z0-9_- in a start payload, at most 64 characters,
  130. // while a subId may hold '#', '&' or non-ASCII; base64url carries any subId
  131. // that fits intact instead of letting the link truncate it into another one.
  132. const maxInvitePayload = 64
  133. func encodeInvitePayload(subID string) (string, bool) {
  134. payload := base64.RawURLEncoding.EncodeToString([]byte(subID))
  135. return payload, len(payload) <= maxInvitePayload
  136. }
  137. func decodeInvitePayload(payload string) (string, bool) {
  138. raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(payload))
  139. if err != nil || len(raw) == 0 {
  140. return "", false
  141. }
  142. return string(raw), true
  143. }
  144. func (t *Tgbot) sendInviteLink(chatId int64, email string) {
  145. record, err := t.clientService.GetRecordByEmail(nil, email)
  146. username := botUsername()
  147. if err != nil || record.SubID == "" || username == "" {
  148. logger.Warning("tgbot: invite link unavailable for", email, err)
  149. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
  150. return
  151. }
  152. payload, ok := encodeInvitePayload(record.SubID)
  153. if !ok {
  154. logger.Warning("tgbot: subId of", email, "is too long for a Telegram invite link")
  155. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
  156. return
  157. }
  158. link := "https://t.me/" + username + "?start=" + payload
  159. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteLink", "Email=="+email, "Link=="+link))
  160. }
  161. // A subId can be short or human-readable, so claim attempts are capped per
  162. // Telegram account: guessing stays slow, and admins hear about whoever tries.
  163. const (
  164. inviteAttemptLimit = 5
  165. inviteAttemptWindow = time.Hour
  166. )
  167. type inviteAttempts struct {
  168. windowStart time.Time
  169. count int
  170. }
  171. var (
  172. inviteAttemptsMu sync.Mutex
  173. inviteAttemptsBy = map[int64]*inviteAttempts{}
  174. inviteAttemptsNow = time.Now
  175. )
  176. // allowInviteAttempt counts one claim attempt and reports whether it may run.
  177. // Admins are told once per window, on the first attempt past the limit.
  178. func (t *Tgbot) allowInviteAttempt(from *telego.User) bool {
  179. now := inviteAttemptsNow()
  180. inviteAttemptsMu.Lock()
  181. for id, a := range inviteAttemptsBy {
  182. if now.Sub(a.windowStart) >= inviteAttemptWindow {
  183. delete(inviteAttemptsBy, id)
  184. }
  185. }
  186. a, ok := inviteAttemptsBy[from.ID]
  187. if !ok {
  188. a = &inviteAttempts{windowStart: now}
  189. inviteAttemptsBy[from.ID] = a
  190. }
  191. a.count++
  192. count := a.count
  193. inviteAttemptsMu.Unlock()
  194. if count == inviteAttemptLimit+1 {
  195. t.SendMsgToTgbotAdmins(t.I18nBot("tgbot.messages.inviteRateLimitedAdmin",
  196. "User=="+tgUserMention(from),
  197. "ID=="+strconv.FormatInt(from.ID, 10),
  198. "Limit=="+strconv.Itoa(inviteAttemptLimit)))
  199. }
  200. return count <= inviteAttemptLimit
  201. }
  202. func tgUserMention(from *telego.User) string {
  203. id := strconv.FormatInt(from.ID, 10)
  204. name := strings.TrimSpace(from.FirstName + " " + from.LastName)
  205. if name == "" {
  206. name = id
  207. }
  208. mention := `<a href="tg://user?id=` + id + `">` + html.EscapeString(name) + `</a>`
  209. if from.Username != "" {
  210. mention += " @" + html.EscapeString(from.Username)
  211. }
  212. return mention
  213. }