291 lines
8.4 KiB
Go
291 lines
8.4 KiB
Go
package domain
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
)
|
|
|
|
const (
|
|
// DefaultPremiumBoostSlotID is the single base boost slot every active premium
|
|
// user owns in the current small-scale implementation.
|
|
DefaultPremiumBoostSlotID = 1
|
|
// MaxPremiumBoostSlotsPerApply bounds premium.applyBoost slots before storage.
|
|
MaxPremiumBoostSlotsPerApply = 16
|
|
// MaxPremiumBoostsListLimit bounds premium.getBoostsList pages.
|
|
MaxPremiumBoostsListLimit = 100
|
|
// MaxPremiumBoostsOffsetBytes bounds opaque boost pagination cursors.
|
|
MaxPremiumBoostsOffsetBytes = 128
|
|
// MaxChannelBoostsToUnblockRestrictions matches Layer 225 TDesktop UI bounds.
|
|
MaxChannelBoostsToUnblockRestrictions = 8
|
|
// MaxDefaultPremiumBoostLevel bounds the built-in level table so status replies
|
|
// never expose unbounded next-level values to clients.
|
|
MaxDefaultPremiumBoostLevel = 100
|
|
// DefaultPremiumBoostReassignCooldownSeconds keeps the current dev behavior of
|
|
// allowing immediate moves while preserving a single hook for official cooldowns.
|
|
DefaultPremiumBoostReassignCooldownSeconds = 0
|
|
)
|
|
|
|
var (
|
|
ErrBoostNotModified = errors.New("boost not modified")
|
|
|
|
defaultPremiumBoostLevelPolicy = PremiumBoostLevelPolicy{
|
|
Thresholds: linearPremiumBoostThresholds(MaxDefaultPremiumBoostLevel),
|
|
}
|
|
)
|
|
|
|
// PremiumBoostSlot describes one user-owned boost slot without TL types.
|
|
type PremiumBoostSlot struct {
|
|
UserID int64
|
|
Slot int
|
|
Peer Peer
|
|
Date int
|
|
Expires int
|
|
CooldownUntil int
|
|
Multiplier int
|
|
Gift bool
|
|
Giveaway bool
|
|
Unclaimed bool
|
|
GiveawayMsgID int
|
|
UsedGiftSlug string
|
|
Stars int64
|
|
}
|
|
|
|
// Active reports whether the slot can currently count for a peer.
|
|
func (s PremiumBoostSlot) Active(now int) bool {
|
|
if s.UserID == 0 || s.Slot <= 0 {
|
|
return false
|
|
}
|
|
return s.Expires == 0 || s.Expires > now
|
|
}
|
|
|
|
// Assigned reports whether the active slot is currently applied to a peer.
|
|
func (s PremiumBoostSlot) Assigned(now int) bool {
|
|
return s.Active(now) && s.Peer.ID != 0
|
|
}
|
|
|
|
// Weight returns the boost contribution of one active assigned slot.
|
|
func (s PremiumBoostSlot) Weight(now int) int {
|
|
if !s.Assigned(now) {
|
|
return 0
|
|
}
|
|
if s.Multiplier <= 0 {
|
|
return 1
|
|
}
|
|
return s.Multiplier
|
|
}
|
|
|
|
// PremiumBoostStatus is the domain projection behind premium.boostsStatus.
|
|
type PremiumBoostStatus struct {
|
|
Peer Peer
|
|
Level int
|
|
CurrentLevelBoosts int
|
|
Boosts int
|
|
GiftBoosts int
|
|
NextLevelBoosts int
|
|
HasNextLevelBoosts bool
|
|
PremiumAudiencePart int
|
|
PremiumAudienceTotal int
|
|
MyBoostSlots []PremiumBoostSlot
|
|
}
|
|
|
|
// PremiumBoostList is a bounded admin-visible boosts page.
|
|
type PremiumBoostList struct {
|
|
Count int
|
|
Boosts []PremiumBoostSlot
|
|
Users []User
|
|
NextOffset string
|
|
}
|
|
|
|
// PremiumMyBoosts is the current user's slot inventory.
|
|
type PremiumMyBoosts struct {
|
|
Slots []PremiumBoostSlot
|
|
Users []User
|
|
Channels []Channel
|
|
}
|
|
|
|
// PremiumBoostLevelPolicy maps cumulative active boosts to channel boost levels.
|
|
// Thresholds[n-1] is the minimum boost count required for level n.
|
|
type PremiumBoostLevelPolicy struct {
|
|
Thresholds []int
|
|
}
|
|
|
|
// DefaultPremiumBoostLevelPolicy returns the current bounded telesrv level table.
|
|
// It is intentionally policy-backed: production deployments can replace the table
|
|
// with official thresholds without touching store/RPC state transitions.
|
|
func DefaultPremiumBoostLevelPolicy() PremiumBoostLevelPolicy {
|
|
return PremiumBoostLevelPolicy{
|
|
Thresholds: append([]int(nil), defaultPremiumBoostLevelPolicy.Thresholds...),
|
|
}
|
|
}
|
|
|
|
// NewPremiumBoostLevelPolicy creates a monotonic threshold policy from raw values.
|
|
func NewPremiumBoostLevelPolicy(thresholds []int) PremiumBoostLevelPolicy {
|
|
return PremiumBoostLevelPolicy{Thresholds: normalizePremiumBoostThresholds(thresholds)}
|
|
}
|
|
|
|
// LevelForBoosts returns level bounds for the given active boost count.
|
|
func (p PremiumBoostLevelPolicy) LevelForBoosts(boosts int) (level, currentLevelBoosts, nextLevelBoosts int, hasNext bool) {
|
|
if boosts < 0 {
|
|
boosts = 0
|
|
}
|
|
thresholds := p.thresholds()
|
|
if len(thresholds) == 0 {
|
|
return 0, 0, 0, false
|
|
}
|
|
level = sort.Search(len(thresholds), func(i int) bool { return thresholds[i] > boosts })
|
|
if level > 0 {
|
|
currentLevelBoosts = thresholds[level-1]
|
|
}
|
|
if level < len(thresholds) {
|
|
return level, currentLevelBoosts, thresholds[level], true
|
|
}
|
|
return len(thresholds), thresholds[len(thresholds)-1], 0, false
|
|
}
|
|
|
|
func (p PremiumBoostLevelPolicy) thresholds() []int {
|
|
if len(p.Thresholds) == 0 {
|
|
return defaultPremiumBoostLevelPolicy.Thresholds
|
|
}
|
|
last := 0
|
|
for _, threshold := range p.Thresholds {
|
|
if threshold <= 0 || threshold <= last {
|
|
return normalizePremiumBoostThresholds(p.Thresholds)
|
|
}
|
|
last = threshold
|
|
}
|
|
return p.Thresholds
|
|
}
|
|
|
|
func normalizePremiumBoostThresholds(thresholds []int) []int {
|
|
if len(thresholds) == 0 {
|
|
return nil
|
|
}
|
|
sorted := make([]int, 0, len(thresholds))
|
|
for _, threshold := range thresholds {
|
|
if threshold > 0 {
|
|
sorted = append(sorted, threshold)
|
|
}
|
|
}
|
|
sort.Ints(sorted)
|
|
out := sorted[:0]
|
|
last := 0
|
|
for _, threshold := range sorted {
|
|
if threshold == last {
|
|
continue
|
|
}
|
|
out = append(out, threshold)
|
|
last = threshold
|
|
}
|
|
return append([]int(nil), out...)
|
|
}
|
|
|
|
func linearPremiumBoostThresholds(maxLevel int) []int {
|
|
if maxLevel <= 0 {
|
|
return nil
|
|
}
|
|
out := make([]int, maxLevel)
|
|
for i := range out {
|
|
out[i] = i + 1
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PremiumBoostStatusForCount returns the bounded default level projection.
|
|
func PremiumBoostStatusForCount(peer Peer, boosts int, my []PremiumBoostSlot) PremiumBoostStatus {
|
|
return PremiumBoostStatusForPolicy(peer, boosts, my, defaultPremiumBoostLevelPolicy)
|
|
}
|
|
|
|
// PremiumBoostStatusForPolicy returns a status projection from an explicit level policy.
|
|
func PremiumBoostStatusForPolicy(peer Peer, boosts int, my []PremiumBoostSlot, policy PremiumBoostLevelPolicy) PremiumBoostStatus {
|
|
if boosts < 0 {
|
|
boosts = 0
|
|
}
|
|
level, current, next, hasNext := policy.LevelForBoosts(boosts)
|
|
return PremiumBoostStatus{
|
|
Peer: peer,
|
|
Level: level,
|
|
CurrentLevelBoosts: current,
|
|
Boosts: boosts,
|
|
NextLevelBoosts: next,
|
|
HasNextLevelBoosts: hasNext,
|
|
MyBoostSlots: append([]PremiumBoostSlot(nil), my...),
|
|
}
|
|
}
|
|
|
|
// ApplyPremiumBoostSlot applies one user-owned slot to a peer and enforces shared
|
|
// lifecycle rules for all store implementations.
|
|
func ApplyPremiumBoostSlot(slot PremiumBoostSlot, userID int64, slotID int, peer Peer, now, premiumUntil, cooldownSeconds int) (PremiumBoostSlot, bool, error) {
|
|
if userID == 0 || slotID <= 0 || peer.Type != PeerTypeChannel || peer.ID == 0 || now < 0 {
|
|
return PremiumBoostSlot{}, false, ErrChannelInvalid
|
|
}
|
|
if premiumUntil <= now {
|
|
return PremiumBoostSlot{}, false, ErrPremiumRequired
|
|
}
|
|
if cooldownSeconds < 0 {
|
|
cooldownSeconds = 0
|
|
}
|
|
if slot.UserID == 0 || slot.Slot == 0 || !slot.Active(now) {
|
|
slot = PremiumBoostSlot{
|
|
UserID: userID,
|
|
Slot: slotID,
|
|
Multiplier: 1,
|
|
}
|
|
}
|
|
if slot.UserID != userID || slot.Slot != slotID {
|
|
return PremiumBoostSlot{}, false, ErrChannelInvalid
|
|
}
|
|
if slot.Multiplier <= 0 {
|
|
slot.Multiplier = 1
|
|
}
|
|
if slot.Assigned(now) && slot.Peer == peer {
|
|
if slot.Expires != 0 && slot.Expires != premiumUntil {
|
|
slot.Expires = premiumUntil
|
|
return slot, true, nil
|
|
}
|
|
return slot, false, ErrBoostNotModified
|
|
}
|
|
if slot.Assigned(now) && slot.Peer != peer && slot.CooldownUntil > now {
|
|
return slot, false, NewPremiumBoostFloodWaitError(slot.CooldownUntil - now)
|
|
}
|
|
wasAssigned := slot.Assigned(now)
|
|
slot.Peer = peer
|
|
slot.Date = now
|
|
slot.Expires = premiumUntil
|
|
if wasAssigned && cooldownSeconds > 0 {
|
|
slot.CooldownUntil = now + cooldownSeconds
|
|
} else if slot.CooldownUntil <= now {
|
|
slot.CooldownUntil = 0
|
|
}
|
|
return slot, true, nil
|
|
}
|
|
|
|
// PremiumBoostFloodWaitError carries remaining seconds for boost reassign cooldowns.
|
|
type PremiumBoostFloodWaitError struct {
|
|
Seconds int
|
|
}
|
|
|
|
func (e PremiumBoostFloodWaitError) Error() string {
|
|
if e.Seconds <= 0 {
|
|
return "premium boost flood wait"
|
|
}
|
|
return fmt.Sprintf("premium boost flood wait %d seconds", e.Seconds)
|
|
}
|
|
|
|
func NewPremiumBoostFloodWaitError(seconds int) error {
|
|
if seconds <= 0 {
|
|
seconds = 1
|
|
}
|
|
return PremiumBoostFloodWaitError{Seconds: seconds}
|
|
}
|
|
|
|
func PremiumBoostFloodWaitSeconds(err error) (int, bool) {
|
|
var wait PremiumBoostFloodWaitError
|
|
if !errors.As(err, &wait) {
|
|
return 0, false
|
|
}
|
|
if wait.Seconds <= 0 {
|
|
return 1, true
|
|
}
|
|
return wait.Seconds, true
|
|
}
|