Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877
This commit is contained in:
commit
ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions
|
|
@ -6,24 +6,27 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
ErrPasswordHashInvalid = errors.New("password hash invalid")
|
||||
ErrSRPIDInvalid = errors.New("srp id invalid")
|
||||
ErrSRPPasswordChanged = errors.New("srp password changed")
|
||||
ErrNewSettingsInvalid = errors.New("new password settings invalid")
|
||||
ErrNewSaltInvalid = errors.New("new password salt invalid")
|
||||
ErrPasswordRecoveryNA = errors.New("password recovery not available")
|
||||
ErrEmailCodeInvalid = errors.New("email code invalid")
|
||||
ErrEmailInvalid = errors.New("email invalid")
|
||||
ErrEmailNotAllowed = errors.New("email not allowed")
|
||||
ErrEmailOccupied = errors.New("email occupied")
|
||||
ErrSessionPasswordNeeded = errors.New("session password needed")
|
||||
ErrPhoneNumberInvalid = errors.New("phone number invalid")
|
||||
ErrPhoneNumberOccupied = errors.New("phone number occupied")
|
||||
ErrPhoneCodeEmpty = errors.New("phone code empty")
|
||||
ErrPhoneCodeInvalid = errors.New("phone code invalid")
|
||||
ErrPhoneCodeExpired = errors.New("phone code expired")
|
||||
ErrPhoneChangeAuthInvalid = errors.New("phone change auth invalid")
|
||||
ErrPhoneChangeForbidden = errors.New("phone change forbidden")
|
||||
ErrPasswordHashInvalid = errors.New("password hash invalid")
|
||||
ErrSRPIDInvalid = errors.New("srp id invalid")
|
||||
ErrSRPPasswordChanged = errors.New("srp password changed")
|
||||
ErrNewSettingsInvalid = errors.New("new password settings invalid")
|
||||
ErrNewSaltInvalid = errors.New("new password salt invalid")
|
||||
ErrPasswordRecoveryNA = errors.New("password recovery not available")
|
||||
ErrRecoveryCodeEmpty = errors.New("recovery code empty")
|
||||
ErrRecoveryCodeInvalid = errors.New("recovery code invalid")
|
||||
ErrPasswordRecoveryExpired = errors.New("password recovery expired")
|
||||
ErrEmailCodeInvalid = errors.New("email code invalid")
|
||||
ErrEmailInvalid = errors.New("email invalid")
|
||||
ErrEmailNotAllowed = errors.New("email not allowed")
|
||||
ErrEmailOccupied = errors.New("email occupied")
|
||||
ErrSessionPasswordNeeded = errors.New("session password needed")
|
||||
ErrPhoneNumberInvalid = errors.New("phone number invalid")
|
||||
ErrPhoneNumberOccupied = errors.New("phone number occupied")
|
||||
ErrPhoneCodeEmpty = errors.New("phone code empty")
|
||||
ErrPhoneCodeInvalid = errors.New("phone code invalid")
|
||||
ErrPhoneCodeExpired = errors.New("phone code expired")
|
||||
ErrPhoneChangeAuthInvalid = errors.New("phone change auth invalid")
|
||||
ErrPhoneChangeForbidden = errors.New("phone change forbidden")
|
||||
)
|
||||
|
||||
type AuthCodeDeliveryKind string
|
||||
|
|
@ -115,9 +118,6 @@ type PasswordSettings struct {
|
|||
// Server-only SRP fields. They are persisted but never exposed to rpc/tg conversion.
|
||||
SRPVerifier []byte
|
||||
SRPBSecret []byte
|
||||
|
||||
RecoveryCode string
|
||||
RecoveryCodeExpiresAt int64
|
||||
}
|
||||
|
||||
// ReactionNotifyFrom stores one account-level reaction notification scope.
|
||||
|
|
|
|||
397
internal/domain/account_rating.go
Normal file
397
internal/domain/account_rating.go
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Composite account rating.
|
||||
//
|
||||
// This is gramsrv's server-local account score. It deliberately uses its own
|
||||
// inputs and thresholds (Stars, activity and moderation), rather than claiming
|
||||
// to reproduce Telegram's private rating algorithm. The RPC edge exposes the
|
||||
// stored level through userFull's existing rating fields so official clients can
|
||||
// render it without a client patch.
|
||||
const (
|
||||
// MaxAccountRatingLevel bounds the local gramsrv level.
|
||||
MaxAccountRatingLevel = 50
|
||||
// accountRatingLevelUnit is the score required for level 1. Thresholds grow
|
||||
// quadratically from it: level n needs accountRatingLevelUnit * n^2.
|
||||
accountRatingLevelUnit = 100
|
||||
// MaxAccountRatingReasonLength matches the event ledger CHECK on reason.
|
||||
MaxAccountRatingReasonLength = 512
|
||||
// MaxAccountRatingActorLength matches the event ledger CHECK on actor.
|
||||
MaxAccountRatingActorLength = 128
|
||||
// MaxAccountRatingCommandKeyLength matches the idempotency CHECK.
|
||||
MaxAccountRatingCommandKeyLength = 128
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAccountRatingNotFound reports a user with no rating row yet.
|
||||
ErrAccountRatingNotFound = errors.New("account rating not found")
|
||||
// ErrAccountRatingWeightsInvalid rejects a non-sensical weight set.
|
||||
ErrAccountRatingWeightsInvalid = errors.New("account rating weights invalid")
|
||||
// ErrAccountRatingAdjustmentInvalid rejects a malformed manual adjustment.
|
||||
ErrAccountRatingAdjustmentInvalid = errors.New("account rating adjustment invalid")
|
||||
)
|
||||
|
||||
// AccountRatingEventKind is the contribution source of a ledger row. Only
|
||||
// 'manual' rows survive a full recompute; the rest are audit trail.
|
||||
type AccountRatingEventKind string
|
||||
|
||||
const (
|
||||
AccountRatingEventStars AccountRatingEventKind = "stars"
|
||||
AccountRatingEventActivity AccountRatingEventKind = "activity"
|
||||
AccountRatingEventModeration AccountRatingEventKind = "moderation"
|
||||
AccountRatingEventManual AccountRatingEventKind = "manual"
|
||||
AccountRatingEventRecompute AccountRatingEventKind = "recompute"
|
||||
)
|
||||
|
||||
// Valid reports whether the kind is modelled.
|
||||
func (k AccountRatingEventKind) Valid() bool {
|
||||
switch k {
|
||||
case AccountRatingEventStars, AccountRatingEventActivity, AccountRatingEventModeration,
|
||||
AccountRatingEventManual, AccountRatingEventRecompute:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AccountRating is the stored read model for one user.
|
||||
type AccountRating struct {
|
||||
UserID int64
|
||||
Level int
|
||||
Stars int64
|
||||
CurrentLevelStars int64
|
||||
// NextLevelStars is meaningful only when HasNextLevel is true.
|
||||
NextLevelStars int64
|
||||
HasNextLevel bool
|
||||
// Components are the explainable breakdown. PenaltyComponent is a
|
||||
// non-negative magnitude that is subtracted.
|
||||
StarsComponent int64
|
||||
ActivityComponent int64
|
||||
PenaltyComponent int64
|
||||
ManualComponent int64
|
||||
// PendingStars is a score delta not yet applied to the visible level, with
|
||||
// PendingDate reporting when it becomes effective.
|
||||
PendingStars int64
|
||||
PendingDate time.Time
|
||||
ComputedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Version int64
|
||||
}
|
||||
|
||||
// AccountRatingLevel is the local client/admin-facing level snapshot.
|
||||
type AccountRatingLevel struct {
|
||||
Level int
|
||||
CurrentLevelStars int64
|
||||
Stars int64
|
||||
NextLevelStars int64
|
||||
HasNextLevelStars bool
|
||||
}
|
||||
|
||||
// RatableAccount reports whether an account may carry a composite rating.
|
||||
//
|
||||
// The rating measures what an account did with Stars -- gifts bought, paid
|
||||
// messages sent, activity, moderation history. Two kinds of account have no
|
||||
// meaningful answer there and are excluded everywhere the rating is computed,
|
||||
// seeded or projected:
|
||||
//
|
||||
// - Bots. A bot does not buy gifts or send paid messages on its own behalf, so
|
||||
// its score would only ever be the flat account-age term.
|
||||
// - The built-in service accounts (the platform account, BotFather, @Stickers,
|
||||
// @ChatBot, the verification bots). They are infrastructure rather than
|
||||
// participants: a leaderboard entry for the platform account is noise, and a
|
||||
// level badge on it would claim something about transaction volume that means
|
||||
// nothing.
|
||||
//
|
||||
// Note that the platform account is not flagged is_bot, so the bot check alone
|
||||
// does not cover it -- which is exactly how it ended up in the seeding pass.
|
||||
func RatableAccount(userID int64, bot bool) bool {
|
||||
return userID > 0 && !bot && !IsSystemUserID(userID)
|
||||
}
|
||||
|
||||
// LevelSnapshot returns the current visible local level.
|
||||
func (r AccountRating) LevelSnapshot() AccountRatingLevel {
|
||||
return AccountRatingLevel{
|
||||
Level: r.Level,
|
||||
CurrentLevelStars: r.CurrentLevelStars,
|
||||
Stars: r.Stars,
|
||||
NextLevelStars: r.NextLevelStars,
|
||||
HasNextLevelStars: r.HasNextLevel,
|
||||
}
|
||||
}
|
||||
|
||||
// PendingLevel returns the local level after the pending score is applied and
|
||||
// reports whether a pending record exists at all.
|
||||
func (r AccountRating) PendingLevel() (AccountRatingLevel, bool) {
|
||||
if r.PendingStars == 0 || r.PendingDate.IsZero() {
|
||||
return AccountRatingLevel{}, false
|
||||
}
|
||||
total := r.Stars + r.PendingStars
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
level, current, next, hasNext := AccountRatingLevelForStars(total)
|
||||
return AccountRatingLevel{
|
||||
Level: level,
|
||||
CurrentLevelStars: current,
|
||||
Stars: total,
|
||||
NextLevelStars: next,
|
||||
HasNextLevelStars: hasNext,
|
||||
}, true
|
||||
}
|
||||
|
||||
// AccountRatingWeights is the composite formula. All weights are integers so the
|
||||
// score is exactly reproducible across a recompute and across store backends.
|
||||
type AccountRatingWeights struct {
|
||||
// StarsReceivedPermille weighs Stars credited to the account (gifts,
|
||||
// reactions, paid messages received), in permille of the raw amount.
|
||||
StarsReceivedPermille int64
|
||||
// StarsSpentPermille weighs Stars the account spent. Spending is a weaker
|
||||
// signal than receiving, so the default is lower.
|
||||
StarsSpentPermille int64
|
||||
// PerMessageSent rewards sustained use.
|
||||
PerMessageSent int64
|
||||
// PerAccountAgeDay rewards account longevity.
|
||||
PerAccountAgeDay int64
|
||||
// PerGiftReceived rewards collectible gifts held.
|
||||
PerGiftReceived int64
|
||||
// PerModerationCase is the penalty for each upheld moderation case.
|
||||
PerModerationCase int64
|
||||
// ScamPenalty and FakePenalty are flat penalties for the peer flags.
|
||||
ScamPenalty int64
|
||||
FakePenalty int64
|
||||
// ActivityCap bounds the activity component so activity alone cannot
|
||||
// outweigh everything else. Zero means uncapped.
|
||||
ActivityCap int64
|
||||
}
|
||||
|
||||
// DefaultAccountRatingWeights returns the shipped local policy. Stars dominate,
|
||||
// activity contributes a bounded floor, and moderation subtracts.
|
||||
func DefaultAccountRatingWeights() AccountRatingWeights {
|
||||
return AccountRatingWeights{
|
||||
StarsReceivedPermille: 1000,
|
||||
StarsSpentPermille: 250,
|
||||
PerMessageSent: 1,
|
||||
PerAccountAgeDay: 2,
|
||||
PerGiftReceived: 25,
|
||||
PerModerationCase: 150,
|
||||
ScamPenalty: 5000,
|
||||
FakePenalty: 5000,
|
||||
ActivityCap: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rejects negative weights and an impossible cap.
|
||||
func (w AccountRatingWeights) Validate() error {
|
||||
values := []int64{
|
||||
w.StarsReceivedPermille, w.StarsSpentPermille, w.PerMessageSent,
|
||||
w.PerAccountAgeDay, w.PerGiftReceived, w.PerModerationCase,
|
||||
w.ScamPenalty, w.FakePenalty, w.ActivityCap,
|
||||
}
|
||||
for _, v := range values {
|
||||
if v < 0 {
|
||||
return ErrAccountRatingWeightsInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccountRatingSignals is the raw snapshot gathered from the contributing
|
||||
// sources for one user. It is deliberately a plain value: the same snapshot must
|
||||
// produce the same score in a unit test and in production.
|
||||
type AccountRatingSignals struct {
|
||||
UserID int64
|
||||
StarsReceived int64
|
||||
StarsSpent int64
|
||||
MessagesSent int64
|
||||
AccountAgeDays int64
|
||||
GiftsReceived int64
|
||||
ModerationCases int64
|
||||
Scam bool
|
||||
Fake bool
|
||||
// Manual is the sum of admin adjustments, carried across recomputes.
|
||||
Manual int64
|
||||
}
|
||||
|
||||
// ComputeAccountRating turns a signal snapshot into the read model. The score is
|
||||
// clamped at zero: penalties can erase this local score but never invert it.
|
||||
func ComputeAccountRating(signals AccountRatingSignals, weights AccountRatingWeights, now time.Time) AccountRating {
|
||||
if err := weights.Validate(); err != nil {
|
||||
weights = DefaultAccountRatingWeights()
|
||||
}
|
||||
starsComponent := permille(max64(signals.StarsReceived, 0), weights.StarsReceivedPermille) +
|
||||
permille(max64(signals.StarsSpent, 0), weights.StarsSpentPermille)
|
||||
|
||||
activityComponent := max64(signals.MessagesSent, 0)*weights.PerMessageSent +
|
||||
max64(signals.AccountAgeDays, 0)*weights.PerAccountAgeDay +
|
||||
max64(signals.GiftsReceived, 0)*weights.PerGiftReceived
|
||||
if weights.ActivityCap > 0 && activityComponent > weights.ActivityCap {
|
||||
activityComponent = weights.ActivityCap
|
||||
}
|
||||
|
||||
penalty := max64(signals.ModerationCases, 0) * weights.PerModerationCase
|
||||
if signals.Scam {
|
||||
penalty += weights.ScamPenalty
|
||||
}
|
||||
if signals.Fake {
|
||||
penalty += weights.FakePenalty
|
||||
}
|
||||
|
||||
total := starsComponent + activityComponent + signals.Manual - penalty
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
level, current, next, hasNext := AccountRatingLevelForStars(total)
|
||||
|
||||
return AccountRating{
|
||||
UserID: signals.UserID,
|
||||
Level: level,
|
||||
Stars: total,
|
||||
CurrentLevelStars: current,
|
||||
NextLevelStars: next,
|
||||
HasNextLevel: hasNext,
|
||||
StarsComponent: starsComponent,
|
||||
ActivityComponent: activityComponent,
|
||||
PenaltyComponent: penalty,
|
||||
ManualComponent: signals.Manual,
|
||||
ComputedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// AccountRatingLevelThreshold returns the score needed to reach the given level.
|
||||
// Level 0 needs nothing; growth is quadratic so early levels arrive quickly and
|
||||
// later ones stay meaningful.
|
||||
func AccountRatingLevelThreshold(level int) int64 {
|
||||
if level <= 0 {
|
||||
return 0
|
||||
}
|
||||
if level > MaxAccountRatingLevel {
|
||||
level = MaxAccountRatingLevel
|
||||
}
|
||||
n := int64(level)
|
||||
return accountRatingLevelUnit * n * n
|
||||
}
|
||||
|
||||
// AccountRatingLevelForStars maps a score onto the level and the surrounding
|
||||
// thresholds. hasNext is false at MaxAccountRatingLevel.
|
||||
func AccountRatingLevelForStars(stars int64) (level int, currentLevelStars int64, nextLevelStars int64, hasNext bool) {
|
||||
if stars < 0 {
|
||||
stars = 0
|
||||
}
|
||||
level = 0
|
||||
for candidate := 1; candidate <= MaxAccountRatingLevel; candidate++ {
|
||||
if stars < AccountRatingLevelThreshold(candidate) {
|
||||
break
|
||||
}
|
||||
level = candidate
|
||||
}
|
||||
currentLevelStars = AccountRatingLevelThreshold(level)
|
||||
if level >= MaxAccountRatingLevel {
|
||||
return level, currentLevelStars, 0, false
|
||||
}
|
||||
return level, currentLevelStars, AccountRatingLevelThreshold(level + 1), true
|
||||
}
|
||||
|
||||
// ResolveAccountRatingPending decides whether a freshly computed score becomes
|
||||
// visible immediately or is parked as pending.
|
||||
//
|
||||
// A score that dropped is applied at once -- a penalty must not sit behind a
|
||||
// delay. A score that grew is parked until delay has elapsed; once the parked
|
||||
// window has passed the pending delta is folded into the visible local rating.
|
||||
func ResolveAccountRatingPending(prev, computed AccountRating, delay time.Duration, now time.Time) AccountRating {
|
||||
out := computed
|
||||
out.Version = prev.Version + 1
|
||||
if out.Version <= 0 {
|
||||
out.Version = 1
|
||||
}
|
||||
if delay <= 0 || prev.UserID == 0 {
|
||||
return out
|
||||
}
|
||||
if computed.Stars <= prev.Stars {
|
||||
return out
|
||||
}
|
||||
// A previously parked delta whose date has arrived is applied now.
|
||||
if prev.PendingStars != 0 && !prev.PendingDate.IsZero() && !now.Before(prev.PendingDate) {
|
||||
return out
|
||||
}
|
||||
pendingSince := prev.PendingDate
|
||||
if prev.PendingStars == 0 || pendingSince.IsZero() {
|
||||
pendingSince = now.Add(delay)
|
||||
}
|
||||
visible := prev
|
||||
visible.StarsComponent = computed.StarsComponent
|
||||
visible.ActivityComponent = computed.ActivityComponent
|
||||
visible.PenaltyComponent = computed.PenaltyComponent
|
||||
visible.ManualComponent = computed.ManualComponent
|
||||
visible.PendingStars = computed.Stars - prev.Stars
|
||||
visible.PendingDate = pendingSince
|
||||
visible.ComputedAt = now
|
||||
visible.UpdatedAt = now
|
||||
visible.Version = out.Version
|
||||
return visible
|
||||
}
|
||||
|
||||
// AccountRatingEvent is one contribution ledger row.
|
||||
type AccountRatingEvent struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Kind AccountRatingEventKind
|
||||
Amount int64
|
||||
Reason string
|
||||
Actor string
|
||||
CommandKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdjustAccountRatingRequest is an operator adjustment to the manual component.
|
||||
type AdjustAccountRatingRequest struct {
|
||||
UserID int64
|
||||
Amount int64
|
||||
Reason string
|
||||
Actor string
|
||||
CommandKey string
|
||||
}
|
||||
|
||||
// Validate rejects a no-op or oversized adjustment.
|
||||
func (r AdjustAccountRatingRequest) Validate() error {
|
||||
if r.UserID <= 0 || r.Amount == 0 {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.Reason) > MaxAccountRatingReasonLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.Actor) > MaxAccountRatingActorLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.CommandKey) > MaxAccountRatingCommandKeyLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccountRatingFilter bounds an admin listing query.
|
||||
type AccountRatingFilter struct {
|
||||
MinLevel int
|
||||
UserID int64
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
func permille(value, weight int64) int64 {
|
||||
if value <= 0 || weight <= 0 {
|
||||
return 0
|
||||
}
|
||||
return value * weight / 1000
|
||||
}
|
||||
|
||||
func max64(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
114
internal/domain/auth_delivery_report.go
Normal file
114
internal/domain/auth_delivery_report.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxAuthDeliveryMNCBytes = 8
|
||||
MaxAuthDeliveryClientTypeBytes = 32
|
||||
MaxAuthDeliveryIDBytes = 128
|
||||
MaxAuthDeliveryReportsPerHour = 10
|
||||
MaxAuthDeliveryReportsPerPhoneDay = 20
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthDeliveryReportInvalid = errors.New("auth delivery report invalid")
|
||||
ErrAuthDeliveryRateLimited = errors.New("auth delivery report rate limited")
|
||||
)
|
||||
|
||||
// AuthDeliveryReport is operational delivery telemetry, not an abuse report.
|
||||
// It deliberately stores only hashes of the phone and phone_code_hash and
|
||||
// never stores the authentication code.
|
||||
type AuthDeliveryReport struct {
|
||||
ID int64
|
||||
AuthKeyID [8]byte
|
||||
SessionID int64
|
||||
ClientType string
|
||||
PhoneHash [sha256.Size]byte
|
||||
CodeHash [sha256.Size]byte
|
||||
IssuedUserID int64
|
||||
DeliveryID string
|
||||
Channel AuthCodeDeliveryKind
|
||||
MNC string
|
||||
Fingerprint [sha256.Size]byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AuthMissingCodeReportRequest struct {
|
||||
AuthKeyID [8]byte
|
||||
SessionID int64
|
||||
ClientType string
|
||||
Phone string
|
||||
PhoneCodeHash string
|
||||
MNC string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewAuthDeliveryReport(authKeyID [8]byte, sessionID int64, clientType, phone, phoneCodeHash string, issuedUserID int64, deliveryID string, channel AuthCodeDeliveryKind, mnc string, createdAt time.Time) (AuthDeliveryReport, error) {
|
||||
report := AuthDeliveryReport{
|
||||
AuthKeyID: authKeyID, SessionID: sessionID, ClientType: clientType,
|
||||
PhoneHash: sha256.Sum256([]byte(phone)), CodeHash: sha256.Sum256([]byte(phoneCodeHash)),
|
||||
IssuedUserID: issuedUserID, DeliveryID: deliveryID,
|
||||
Channel: channel, MNC: mnc, CreatedAt: createdAt,
|
||||
}
|
||||
raw, err := json.Marshal(struct {
|
||||
Version int
|
||||
AuthKeyID [8]byte
|
||||
SessionID int64
|
||||
PhoneHash [sha256.Size]byte
|
||||
CodeHash [sha256.Size]byte
|
||||
DeliveryID string
|
||||
Channel AuthCodeDeliveryKind
|
||||
MNC string
|
||||
}{
|
||||
Version: 1, AuthKeyID: authKeyID, SessionID: sessionID,
|
||||
PhoneHash: report.PhoneHash, CodeHash: report.CodeHash,
|
||||
DeliveryID: deliveryID, Channel: channel, MNC: mnc,
|
||||
})
|
||||
if err != nil {
|
||||
return AuthDeliveryReport{}, ErrAuthDeliveryReportInvalid
|
||||
}
|
||||
report.Fingerprint = sha256.Sum256(raw)
|
||||
if err := report.Validate(); err != nil {
|
||||
return AuthDeliveryReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (r AuthDeliveryReport) Validate() error {
|
||||
if r.ID < 0 || r.AuthKeyID == ([8]byte{}) || r.SessionID == 0 ||
|
||||
r.PhoneHash == ([sha256.Size]byte{}) || r.CodeHash == ([sha256.Size]byte{}) ||
|
||||
r.Fingerprint == ([sha256.Size]byte{}) || r.IssuedUserID < 0 ||
|
||||
len(r.ClientType) > MaxAuthDeliveryClientTypeBytes || !utf8.ValidString(r.ClientType) ||
|
||||
len(r.DeliveryID) > MaxAuthDeliveryIDBytes || !utf8.ValidString(r.DeliveryID) ||
|
||||
!validAuthDeliveryChannel(r.Channel) || !validMNC(r.MNC) || r.CreatedAt.IsZero() {
|
||||
return ErrAuthDeliveryReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validAuthDeliveryChannel(channel AuthCodeDeliveryKind) bool {
|
||||
switch channel {
|
||||
case AuthCodeDeliveryPhone, AuthCodeDeliverySMS:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validMNC(mnc string) bool {
|
||||
if len(mnc) > MaxAuthDeliveryMNCBytes {
|
||||
return false
|
||||
}
|
||||
for _, r := range mnc {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
369
internal/domain/bot_verification.go
Normal file
369
internal/domain/bot_verification.go
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Third-party bot verification (core.telegram.org/api/bots/verification).
|
||||
//
|
||||
// A verifier bot marks a peer with its OWN icon and description. Clients render
|
||||
// that icon before the name and the description in the profile, which is
|
||||
// deliberately different from the official checkmark: only the operator grants the
|
||||
// platform flag (see verification.go), and the two mechanisms never read each
|
||||
// other's state.
|
||||
//
|
||||
// Layer 228 shapes this feature projects onto:
|
||||
//
|
||||
// botVerification#f93cd45c bot_id:long icon:long description:string
|
||||
// botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true
|
||||
// icon:long company:string custom_description:flags.0?string
|
||||
// bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true
|
||||
// bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool
|
||||
// user#b1b8cc83 bot_verification_icon:flags2.14?long
|
||||
// channel#d49f34c6 bot_verification_icon:flags2.13?long
|
||||
// userFull#6cbe645 bot_verification:flags2.12?BotVerification
|
||||
// channelFull#a04e8d3a bot_verification:flags2.17?BotVerification
|
||||
// chatInvite#5c9d3702 bot_verification:flags.13?BotVerification
|
||||
// botInfo#4d8a0299 verifier_settings:flags.9?BotVerifierSettings
|
||||
//
|
||||
// The icon is a custom emoji document id. Clients resolve it through
|
||||
// messages.getCustomEmojiDocuments, so an id that names no fetchable document
|
||||
// renders as nothing at all -- which is why the catalogue is validated against
|
||||
// real documents rather than accepting arbitrary numbers.
|
||||
const (
|
||||
// MaxVerifierCompanyLength bounds botVerifierSettings.company.
|
||||
MaxVerifierCompanyLength = 128
|
||||
// MaxCustomVerificationDescriptionLength is the app-configured limit for text
|
||||
// supplied by a verifier (bot_verification_description_length_limit).
|
||||
MaxCustomVerificationDescriptionLength = 70
|
||||
// MaxBotVerificationDescriptionLength bounds the final wire description. It
|
||||
// may exceed the custom-input limit because the server-generated fallback
|
||||
// includes the organization name.
|
||||
MaxBotVerificationDescriptionLength = 1024
|
||||
// MaxVerificationIconNameLength bounds an operator-facing catalogue label.
|
||||
MaxVerificationIconNameLength = 128
|
||||
// MaxCustomVerificationReasonLength bounds an applicant's stated reason.
|
||||
MaxCustomVerificationReasonLength = 4096
|
||||
// MaxCustomVerificationNoteLength bounds an operator-only note.
|
||||
MaxCustomVerificationNoteLength = 8192
|
||||
// MaxVerifierGrantReasonLength bounds the reason a bot was made a verifier.
|
||||
MaxVerifierGrantReasonLength = 1024
|
||||
// MaxCustomVerificationsPerVerifier bounds how many peers one verifier may
|
||||
// mark. Verifier status is granted per deployment, not earned per peer, so an
|
||||
// unbounded verifier would be an unbounded badge printer.
|
||||
MaxCustomVerificationsPerVerifier = 10000
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrVerifierNotFound reports a bot without verifier status.
|
||||
ErrVerifierNotFound = errors.New("bot verifier settings not found")
|
||||
// ErrVerifierForbidden reports a bot that may not verify: no verifier row, or a
|
||||
// row the operator disabled. It maps to BOT_VERIFIER_FORBIDDEN.
|
||||
ErrVerifierForbidden = errors.New("bot is not allowed to verify peers")
|
||||
// ErrVerifierSettingsInvalid rejects a malformed verifier configuration.
|
||||
ErrVerifierSettingsInvalid = errors.New("bot verifier settings invalid")
|
||||
// ErrVerifierDescriptionForbidden reports a per-peer description supplied by a
|
||||
// verifier whose can_modify_custom_description is false.
|
||||
ErrVerifierDescriptionForbidden = errors.New("verifier may not set a custom description")
|
||||
// ErrVerificationIconNotFound reports an icon missing from the catalogue.
|
||||
ErrVerificationIconNotFound = errors.New("verification icon not found")
|
||||
// ErrVerificationIconInactive rejects an icon the operator retired.
|
||||
ErrVerificationIconInactive = errors.New("verification icon inactive")
|
||||
// ErrVerificationIconInvalid rejects a malformed icon record.
|
||||
ErrVerificationIconInvalid = errors.New("verification icon invalid")
|
||||
// ErrCustomVerificationNotFound reports a peer this verifier has not marked.
|
||||
ErrCustomVerificationNotFound = errors.New("custom verification not found")
|
||||
// ErrCustomVerificationLimit reports the per-verifier bound.
|
||||
ErrCustomVerificationLimit = errors.New("custom verification limit reached")
|
||||
// ErrCustomVerificationTargetInvalid rejects an unverifiable peer.
|
||||
ErrCustomVerificationTargetInvalid = errors.New("custom verification target invalid")
|
||||
// ErrCustomVerificationRequestNotFound reports a missing application.
|
||||
ErrCustomVerificationRequestNotFound = errors.New("custom verification request not found")
|
||||
// ErrCustomVerificationRequestExists reports a pending application already
|
||||
// occupying the (verifier, peer) pair.
|
||||
ErrCustomVerificationRequestExists = errors.New("custom verification request already pending")
|
||||
// ErrCustomVerificationRequestInvalid rejects a malformed application.
|
||||
ErrCustomVerificationRequestInvalid = errors.New("custom verification request invalid")
|
||||
// ErrCustomVerificationVersionConflict reports a lost optimistic-locking race.
|
||||
ErrCustomVerificationVersionConflict = errors.New("custom verification changed concurrently")
|
||||
)
|
||||
|
||||
// VerificationIcon is one catalogue entry: a custom emoji document usable as a
|
||||
// verifier's mark.
|
||||
type VerificationIcon struct {
|
||||
ID int64
|
||||
DocumentID int64
|
||||
// OwnerBotID is zero for a shared entry and a bot id when the operator
|
||||
// reserved the icon for one verifier.
|
||||
OwnerBotID int64
|
||||
Name string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate checks the catalogue entry shape.
|
||||
func (i VerificationIcon) Validate() error {
|
||||
if i.DocumentID <= 0 {
|
||||
return ErrVerificationIconInvalid
|
||||
}
|
||||
name := strings.TrimSpace(i.Name)
|
||||
if name == "" || utf8.RuneCountInString(name) > MaxVerificationIconNameLength {
|
||||
return ErrVerificationIconInvalid
|
||||
}
|
||||
if i.OwnerBotID < 0 {
|
||||
return ErrVerificationIconInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsableBy reports whether a verifier may mark peers with this icon.
|
||||
func (i VerificationIcon) UsableBy(botID int64) bool {
|
||||
return i.Active && (i.OwnerBotID == 0 || i.OwnerBotID == botID)
|
||||
}
|
||||
|
||||
// BotVerifierSettings is a bot's verifier status, projected onto
|
||||
// botVerifierSettings#b0cd6617 and consulted by bots.setCustomVerification.
|
||||
type BotVerifierSettings struct {
|
||||
BotID int64
|
||||
IconDocumentID int64
|
||||
CompanyName string
|
||||
DefaultDescription string
|
||||
// CanModifyCustomDescription mirrors flags.1 of the TL constructor: when false
|
||||
// the verifier may only apply DefaultDescription.
|
||||
CanModifyCustomDescription bool
|
||||
// Enabled is the operator's kill switch. A disabled verifier keeps its row and
|
||||
// its granted marks but can no longer mark anything new, and the settings stop
|
||||
// being projected into botInfo.
|
||||
Enabled bool
|
||||
GrantedBy string
|
||||
GrantReason string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Version int64
|
||||
}
|
||||
|
||||
// Validate checks the verifier configuration.
|
||||
func (s BotVerifierSettings) Validate() error {
|
||||
if s.BotID <= 0 || s.IconDocumentID <= 0 {
|
||||
return ErrVerifierSettingsInvalid
|
||||
}
|
||||
company := strings.TrimSpace(s.CompanyName)
|
||||
if company == "" || utf8.RuneCountInString(company) > MaxVerifierCompanyLength {
|
||||
return ErrVerifierSettingsInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(s.DefaultDescription) > MaxCustomVerificationDescriptionLength {
|
||||
return ErrVerifierSettingsInvalid
|
||||
}
|
||||
if len(s.GrantedBy) > 128 || utf8.RuneCountInString(s.GrantReason) > MaxVerifierGrantReasonLength {
|
||||
return ErrVerifierSettingsInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DescriptionFor resolves the description a mark carries: the verifier-supplied
|
||||
// one when allowed, the configured default, or the protocol-defined generated
|
||||
// fallback.
|
||||
func (s BotVerifierSettings) DescriptionFor(custom string) (string, error) {
|
||||
custom = strings.TrimSpace(custom)
|
||||
if custom == "" {
|
||||
if fallback := strings.TrimSpace(s.DefaultDescription); fallback != "" {
|
||||
return fallback, nil
|
||||
}
|
||||
return fmt.Sprintf(`Was verified by organization "%s"`, strings.TrimSpace(s.CompanyName)), nil
|
||||
}
|
||||
if !s.CanModifyCustomDescription {
|
||||
return "", ErrVerifierDescriptionForbidden
|
||||
}
|
||||
if utf8.RuneCountInString(custom) > MaxCustomVerificationDescriptionLength {
|
||||
return "", ErrCustomVerificationRequestInvalid
|
||||
}
|
||||
return custom, nil
|
||||
}
|
||||
|
||||
// CustomVerification is one granted third-party mark.
|
||||
type CustomVerification struct {
|
||||
ID int64
|
||||
VerifierBotID int64
|
||||
Peer Peer
|
||||
// IconDocumentID is denormalised at grant time so the mark keeps rendering the
|
||||
// icon it was granted with even after the verifier changes its own.
|
||||
IconDocumentID int64
|
||||
Description string
|
||||
GrantedByUserID int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Version int64
|
||||
}
|
||||
|
||||
// BotVerification is the TL projection (botVerification#f93cd45c).
|
||||
type BotVerification struct {
|
||||
BotID int64
|
||||
Icon int64
|
||||
Description string
|
||||
}
|
||||
|
||||
// Projection returns the botVerification payload for full peer objects.
|
||||
func (v CustomVerification) Projection() BotVerification {
|
||||
return BotVerification{
|
||||
BotID: v.VerifierBotID,
|
||||
Icon: v.IconDocumentID,
|
||||
Description: v.Description,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks the mark shape.
|
||||
func (v CustomVerification) Validate() error {
|
||||
if v.VerifierBotID <= 0 || v.IconDocumentID <= 0 {
|
||||
return ErrCustomVerificationTargetInvalid
|
||||
}
|
||||
if !validCustomVerificationPeer(v.Peer) {
|
||||
return ErrCustomVerificationTargetInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(v.Description) > MaxBotVerificationDescriptionLength {
|
||||
return ErrCustomVerificationRequestInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validCustomVerificationPeer(peer Peer) bool {
|
||||
switch peer.Type {
|
||||
case PeerTypeUser, PeerTypeChannel:
|
||||
return peer.ID > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CustomVerificationRequestStatus is the application lifecycle in front of a mark.
|
||||
type CustomVerificationRequestStatus string
|
||||
|
||||
const (
|
||||
CustomVerificationPending CustomVerificationRequestStatus = "pending"
|
||||
CustomVerificationApproved CustomVerificationRequestStatus = "approved"
|
||||
CustomVerificationRejected CustomVerificationRequestStatus = "rejected"
|
||||
CustomVerificationRevoked CustomVerificationRequestStatus = "revoked"
|
||||
)
|
||||
|
||||
// Valid reports whether the status is modelled.
|
||||
func (s CustomVerificationRequestStatus) Valid() bool {
|
||||
switch s {
|
||||
case CustomVerificationPending, CustomVerificationApproved,
|
||||
CustomVerificationRejected, CustomVerificationRevoked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CanTransitionCustomVerificationStatus is the status machine. A revoked mark is
|
||||
// reached only from approved, which keeps "revoked" meaning "was verified once".
|
||||
func CanTransitionCustomVerificationStatus(from, to CustomVerificationRequestStatus) bool {
|
||||
if !from.Valid() || !to.Valid() || from == to {
|
||||
return false
|
||||
}
|
||||
switch from {
|
||||
case CustomVerificationPending:
|
||||
return to == CustomVerificationApproved || to == CustomVerificationRejected
|
||||
case CustomVerificationApproved:
|
||||
return to == CustomVerificationRevoked
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CustomVerificationRequest is an application filed with a verifier bot.
|
||||
type CustomVerificationRequest struct {
|
||||
ID int64
|
||||
VerifierBotID int64
|
||||
ApplicantUserID int64
|
||||
Peer Peer
|
||||
PeerTitle string
|
||||
PeerUsername string
|
||||
Reason string
|
||||
RequestedDescription string
|
||||
Status CustomVerificationRequestStatus
|
||||
DecidedBy string
|
||||
DecisionReason string
|
||||
InternalNote string
|
||||
CorrelationID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ApprovedAt time.Time
|
||||
RejectedAt time.Time
|
||||
Version int64
|
||||
}
|
||||
|
||||
// Validate checks the application shape.
|
||||
func (r CustomVerificationRequest) Validate() error {
|
||||
if r.VerifierBotID <= 0 || r.ApplicantUserID <= 0 || !validCustomVerificationPeer(r.Peer) {
|
||||
return ErrCustomVerificationRequestInvalid
|
||||
}
|
||||
if !r.Status.Valid() {
|
||||
return ErrCustomVerificationRequestInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(r.Reason) > MaxCustomVerificationReasonLength ||
|
||||
utf8.RuneCountInString(r.RequestedDescription) > MaxCustomVerificationDescriptionLength ||
|
||||
utf8.RuneCountInString(r.InternalNote) > MaxCustomVerificationNoteLength {
|
||||
return ErrCustomVerificationRequestInvalid
|
||||
}
|
||||
if r.Status == CustomVerificationRejected && strings.TrimSpace(r.DecisionReason) == "" {
|
||||
return ErrVerificationReasonRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCustomVerificationRequest is the bots.setCustomVerification payload after the
|
||||
// RPC edge has resolved the caller, the verifier bot and the target peer.
|
||||
type SetCustomVerificationRequest struct {
|
||||
VerifierBotID int64
|
||||
Peer Peer
|
||||
// Enabled false revokes the mark this verifier granted; the request then
|
||||
// carries no description.
|
||||
Enabled bool
|
||||
// CustomDescription is the verifier-supplied per-peer text. It is only honoured
|
||||
// when the verifier's settings allow it.
|
||||
CustomDescription string
|
||||
// CallerUserID is the account that invoked the RPC: the bot itself or its owner.
|
||||
CallerUserID int64
|
||||
}
|
||||
|
||||
// Validate checks the request shape without consulting stored state.
|
||||
func (r SetCustomVerificationRequest) Validate() error {
|
||||
if r.VerifierBotID <= 0 || !validCustomVerificationPeer(r.Peer) {
|
||||
return ErrCustomVerificationTargetInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(r.CustomDescription) > MaxCustomVerificationDescriptionLength {
|
||||
return ErrCustomVerificationRequestInvalid
|
||||
}
|
||||
if !r.Enabled && strings.TrimSpace(r.CustomDescription) != "" {
|
||||
// Revocation with a description is a caller bug worth reporting rather than
|
||||
// silently ignoring: it usually means enabled was left unset by mistake.
|
||||
return ErrCustomVerificationRequestInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CustomVerificationFilter bounds an admin listing query.
|
||||
type CustomVerificationFilter struct {
|
||||
VerifierBotID int64
|
||||
PeerType PeerType
|
||||
PeerID int64
|
||||
Query string
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
// CustomVerificationRequestFilter bounds a review-queue query.
|
||||
type CustomVerificationRequestFilter struct {
|
||||
Statuses []CustomVerificationRequestStatus
|
||||
VerifierBotID int64
|
||||
PeerType PeerType
|
||||
Query string
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
|
@ -485,29 +485,50 @@ func (c Channel) MembersListAdminOnly() bool {
|
|||
|
||||
// ChannelMember is one user's channel membership and read state.
|
||||
type ChannelMember struct {
|
||||
ChannelID int64
|
||||
UserID int64
|
||||
InviterUserID int64
|
||||
Role ChannelMemberRole
|
||||
Status ChannelMemberStatus
|
||||
JoinedAt int
|
||||
LeftAt int
|
||||
AdminRights ChannelAdminRights
|
||||
BannedRights ChannelBannedRights
|
||||
Rank string
|
||||
AvailableMinID int
|
||||
AvailableMinPts int
|
||||
ReadInboxMaxID int
|
||||
ReadInboxDate int
|
||||
ReadOutboxMaxID int
|
||||
UnreadMark bool
|
||||
SlowmodeLastSendDate int
|
||||
ChannelID int64
|
||||
UserID int64
|
||||
InviterUserID int64
|
||||
Role ChannelMemberRole
|
||||
Status ChannelMemberStatus
|
||||
JoinedAt int
|
||||
LeftAt int
|
||||
AdminRights ChannelAdminRights
|
||||
BannedRights ChannelBannedRights
|
||||
Rank string
|
||||
AvailableMinID int
|
||||
AvailableMinPts int
|
||||
// HistoryClearAnchorID/Date identify an owner-local channels.deleteHistory
|
||||
// boundary. They are deliberately separate from AvailableMinID: joining a
|
||||
// hidden-prehistory channel also advances AvailableMinID but must not
|
||||
// manufacture a "History cleared" service message.
|
||||
HistoryClearAnchorID int
|
||||
HistoryClearAnchorDate int
|
||||
ReadInboxMaxID int
|
||||
ReadInboxDate int
|
||||
ReadOutboxMaxID int
|
||||
UnreadMark bool
|
||||
SlowmodeLastSendDate int
|
||||
// Guest is a computed, non-persisted view used for subscribers accessing a
|
||||
// private linked discussion group without joining it. Guest must never be
|
||||
// written to channel_members and is still projected to clients as left.
|
||||
Guest bool
|
||||
}
|
||||
|
||||
// CanInviteUsers reports whether this active member may directly add users.
|
||||
// Keep this predicate aligned with both memory/postgres write boundaries so an
|
||||
// RPC cannot expose another user's privacy decision before authorizing the
|
||||
// actor.
|
||||
func (m ChannelMember) CanInviteUsers(channel Channel) bool {
|
||||
if m.Status != ChannelMemberActive {
|
||||
return false
|
||||
}
|
||||
if m.Role == ChannelRoleCreator ||
|
||||
(m.Role == ChannelRoleAdmin && (m.AdminRights.InviteUsers || m.AdminRights.ChangeInfo)) {
|
||||
return true
|
||||
}
|
||||
return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !m.BannedRights.InviteUsers
|
||||
}
|
||||
|
||||
// CanManageDirectMessages reports whether this active parent-channel member may
|
||||
// see and address every subscriber topic in the linked direct-messages
|
||||
// monoforum. Telegram deliberately does not grant this capability to an
|
||||
|
|
@ -530,22 +551,24 @@ func (m ChannelMember) CanPostChannelMessages() bool {
|
|||
|
||||
// ChannelDialog is the current user's owner-view dialog state for a channel.
|
||||
type ChannelDialog struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
FolderID int
|
||||
TopMessageID int
|
||||
TopMessageDate int
|
||||
ReadInboxMaxID int
|
||||
ReadOutboxMaxID int
|
||||
UnreadCount int
|
||||
UnreadMentions int
|
||||
UnreadReactions int
|
||||
Pinned bool
|
||||
PinnedOrder int
|
||||
UnreadMark bool
|
||||
ViewForumAsMessages bool
|
||||
HasScheduled bool
|
||||
DefaultSendAs *Peer
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
FolderID int
|
||||
TopMessageID int
|
||||
TopMessageDate int
|
||||
HistoryClearAnchorID int
|
||||
HistoryClearAnchorDate int
|
||||
ReadInboxMaxID int
|
||||
ReadOutboxMaxID int
|
||||
UnreadCount int
|
||||
UnreadMentions int
|
||||
UnreadReactions int
|
||||
Pinned bool
|
||||
PinnedOrder int
|
||||
UnreadMark bool
|
||||
ViewForumAsMessages bool
|
||||
HasScheduled bool
|
||||
DefaultSendAs *Peer
|
||||
}
|
||||
|
||||
// ChannelMessageActionType identifies service messages generated by channel operations.
|
||||
|
|
@ -558,7 +581,11 @@ const (
|
|||
ChannelActionChatDelete ChannelMessageActionType = "chat_delete_user"
|
||||
ChannelActionChatEditPhoto ChannelMessageActionType = "chat_edit_photo"
|
||||
ChannelActionChatDeletePhoto ChannelMessageActionType = "chat_delete_photo"
|
||||
ChannelActionChatJoined ChannelMessageActionType = "chat_joined"
|
||||
// ChannelActionHistoryClear is an owner-local projection at the member's
|
||||
// channels.deleteHistory boundary. It is never persisted into the shared
|
||||
// channel_messages row and never produces channel PTS.
|
||||
ChannelActionHistoryClear ChannelMessageActionType = "history_clear"
|
||||
ChannelActionChatJoined ChannelMessageActionType = "chat_joined"
|
||||
// ChannelActionChatJoinedByLink 是经邀请链接加入的服务消息,
|
||||
// 渲染为 "X joined the group via invite link"。
|
||||
ChannelActionChatJoinedByLink ChannelMessageActionType = "chat_joined_by_link"
|
||||
|
|
@ -695,6 +722,39 @@ type ChannelMessage struct {
|
|||
Deleted bool
|
||||
}
|
||||
|
||||
// ProjectChannelHistoryClearMessage returns the owner-local service-message
|
||||
// projection for one channel history boundary. Identity fields from the shared
|
||||
// source are retained when available, while all user payload, media, reply,
|
||||
// reaction, pin, TTL and edit state is removed. A zero source is valid for a
|
||||
// retained anchor whose shared row has already been physically pruned.
|
||||
func ProjectChannelHistoryClearMessage(source ChannelMessage, channelID int64, messageID, messageDate int) ChannelMessage {
|
||||
if source.ChannelID == 0 {
|
||||
source.ChannelID = channelID
|
||||
}
|
||||
if source.ID == 0 {
|
||||
source.ID = messageID
|
||||
}
|
||||
if source.Date == 0 {
|
||||
source.Date = messageDate
|
||||
}
|
||||
return ChannelMessage{
|
||||
ChannelID: source.ChannelID,
|
||||
ID: source.ID,
|
||||
SenderUserID: source.SenderUserID,
|
||||
From: source.From,
|
||||
Date: source.Date,
|
||||
Post: source.Post,
|
||||
Silent: source.Silent,
|
||||
Action: &ChannelMessageAction{Type: ChannelActionHistoryClear},
|
||||
}
|
||||
}
|
||||
|
||||
// IsChannelHistoryClearMessage reports whether msg is an owner-local history
|
||||
// clear projection rather than a shared channel service message.
|
||||
func IsChannelHistoryClearMessage(msg ChannelMessage) bool {
|
||||
return msg.Action != nil && msg.Action.Type == ChannelActionHistoryClear
|
||||
}
|
||||
|
||||
// MessageReactionType identifies one stored reaction constructor without depending on TL types.
|
||||
type MessageReactionType string
|
||||
|
||||
|
|
@ -787,8 +847,11 @@ type ChannelMessagePeerReaction struct {
|
|||
// ChannelMessageReactions is the read model carried by channel messages and reaction updates.
|
||||
type ChannelMessageReactions struct {
|
||||
CanSeeList bool
|
||||
Results []ChannelMessageReactionCount
|
||||
Recent []ChannelMessagePeerReaction
|
||||
// AsTags marks reactions on Saved Messages as private message tags.
|
||||
// It is never set for ordinary private/channel reactions.
|
||||
AsTags bool
|
||||
Results []ChannelMessageReactionCount
|
||||
Recent []ChannelMessagePeerReaction
|
||||
// Paid 是付费 reaction(Stars)聚合(nil = 无);读路径从 channel_message_paid_reactions
|
||||
// 填充、tg 转换注入 ReactionPaid 计数 + top reactors。与普通 reaction 分表存储。
|
||||
Paid *ChannelMessagePaidReactions
|
||||
|
|
@ -869,6 +932,22 @@ type ChannelMessageReactionsList struct {
|
|||
NextOffset string
|
||||
}
|
||||
|
||||
// ChannelMessageReactionLookupRequest is the bounded exact lookup used by
|
||||
// moderation evidence capture. It avoids paging an arbitrarily large reactor
|
||||
// list merely to prove that one named participant reacted.
|
||||
type ChannelMessageReactionLookupRequest struct {
|
||||
ViewerUserID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
ReactorUserID int64
|
||||
}
|
||||
|
||||
type ChannelMessageReactionLookup struct {
|
||||
Channel Channel
|
||||
Message ChannelMessage
|
||||
Reactions []ChannelMessagePeerReaction
|
||||
}
|
||||
|
||||
// RecentMessageReaction is one account-level recently used message reaction.
|
||||
type RecentMessageReaction struct {
|
||||
UserID int64
|
||||
|
|
@ -892,6 +971,14 @@ type SavedReactionTag struct {
|
|||
Count int
|
||||
}
|
||||
|
||||
// SavedReactionTagsRequest lists account-level Saved Messages tags. SavedPeer
|
||||
// is zero for the global list and non-zero for one Saved Messages sub-dialog.
|
||||
type SavedReactionTagsRequest struct {
|
||||
UserID int64
|
||||
SavedPeer Peer
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ChannelDiscussionRef links a broadcast post to its discussion megagroup root message.
|
||||
type ChannelDiscussionRef struct {
|
||||
ChannelID int64
|
||||
|
|
@ -1257,10 +1344,14 @@ type ChannelDifference struct {
|
|||
Timeout int
|
||||
}
|
||||
|
||||
// DirtyChannel identifies an active channel with channel-scoped updates after an account difference date.
|
||||
// DirtyChannel identifies an active channel with shared channel updates and/or
|
||||
// an owner-local no-PTS history-clear boundary after an account difference date.
|
||||
type DirtyChannel struct {
|
||||
ChannelID int64
|
||||
Pts int
|
||||
ChannelID int64
|
||||
Pts int
|
||||
ChannelUpdatesDirty bool
|
||||
AvailableMinID int
|
||||
HistoryClearDate int
|
||||
}
|
||||
|
||||
// ChannelUpdateRetentionCheckpoint is the durable recovery boundary for one channel.
|
||||
|
|
@ -1536,6 +1627,7 @@ type SendMonoforumMessageRequest struct {
|
|||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
SuggestedPost *SuggestedPost
|
||||
|
|
@ -1571,6 +1663,23 @@ const (
|
|||
SuggestedPostStateRefunded SuggestedPostLifecycleState = "refunded"
|
||||
)
|
||||
|
||||
const MaxSuggestedPostScheduleDelay = 31 * 24 * 60 * 60
|
||||
|
||||
// EffectiveSuggestedPostPublishDate validates an approval schedule against the
|
||||
// server's bounded future window and converts a missing or already-due absolute
|
||||
// date into an immediate publication at now. Client date pickers can legally
|
||||
// submit after their selected time has crossed a relative minimum; that delay
|
||||
// must not turn a valid approval into an error or persist a past accepted date.
|
||||
func EffectiveSuggestedPostPublishDate(scheduleDate, now int) (int, error) {
|
||||
if now <= 0 || scheduleDate > now+MaxSuggestedPostScheduleDelay {
|
||||
return 0, ErrSuggestedPostInvalid
|
||||
}
|
||||
if scheduleDate <= now {
|
||||
return now, nil
|
||||
}
|
||||
return scheduleDate, nil
|
||||
}
|
||||
|
||||
// ToggleSuggestedPostApprovalResult contains every durable update produced by
|
||||
// one command or lifecycle transition. OriginalEvent is an edit in the
|
||||
// monoforum; ServiceEvent is the approval/success/refund service message; an
|
||||
|
|
@ -1921,6 +2030,9 @@ type DeleteChannelHistoryResult struct {
|
|||
Recipients []int64
|
||||
Offset int
|
||||
AvailableMinID int
|
||||
// AvailableMinChanged is true only when this owner-local request advanced
|
||||
// the member boundary and installed a new history-clear anchor.
|
||||
AvailableMinChanged bool
|
||||
}
|
||||
|
||||
// UpdateChannelPinnedMessageRequest pins or unpins one channel/supergroup message.
|
||||
|
|
@ -2133,15 +2245,26 @@ type ChannelHistoryFilter struct {
|
|||
SenderUserID int64
|
||||
PinnedOnly bool
|
||||
MusicOnly bool
|
||||
OffsetID int
|
||||
OffsetDate int
|
||||
AddOffset int
|
||||
Limit int
|
||||
MinDate int
|
||||
MaxDate int
|
||||
MaxID int
|
||||
MinID int
|
||||
Hash int64
|
||||
// IncludeHistoryClearAnchor is set only by messages.getHistory. Search,
|
||||
// media and topic projections must not count the owner-local service marker
|
||||
// as shared channel content.
|
||||
IncludeHistoryClearAnchor bool
|
||||
// NeedTotalCount requests the exact number of messages matching the static
|
||||
// filters before offset/add_offset pagination. Ordinary history pages leave
|
||||
// this false and keep the bounded len(page)+has-more hint.
|
||||
NeedTotalCount bool
|
||||
// CountOnly skips message hydration and returns only the exact Count plus
|
||||
// the viewer-scoped channel metadata needed for access validation.
|
||||
CountOnly bool
|
||||
OffsetID int
|
||||
OffsetDate int
|
||||
AddOffset int
|
||||
Limit int
|
||||
MinDate int
|
||||
MaxDate int
|
||||
MaxID int
|
||||
MinID int
|
||||
Hash int64
|
||||
}
|
||||
|
||||
// ChannelSearchPostsRequest describes a bounded global public post search.
|
||||
|
|
@ -2283,7 +2406,11 @@ type ReadChannelHistoryResult struct {
|
|||
MaxID int
|
||||
StillUnreadCount int
|
||||
Changed bool
|
||||
Pts int
|
||||
// ReadOnly marks a synthetic viewer (public preview, linked guest or
|
||||
// monoforum shell). The read is acknowledged without creating member,
|
||||
// dialog, watermark, receipt or update state.
|
||||
ReadOnly bool
|
||||
Pts int
|
||||
// Forum 标记该频道是否为话题群。RPC 层据此在频道级 readHistory 后顺带推进
|
||||
// General(topic 1) 的话题级已读水位(General 消息即频道根历史,被频道级已读覆盖)。
|
||||
Forum bool
|
||||
|
|
|
|||
131
internal/domain/client_telemetry.go
Normal file
131
internal/domain/client_telemetry.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxClientTelemetrySubjects = 100
|
||||
MaxClientTelemetryPayloadBytes = 64 << 10
|
||||
MaxClientTelemetryEventsPerHour = 1000
|
||||
MaxClientTelemetryEventsPerDay = 10000
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClientTelemetryInvalid = errors.New("client telemetry invalid")
|
||||
ErrClientTelemetryRateLimited = errors.New("client telemetry rate limited")
|
||||
)
|
||||
|
||||
type ClientTelemetryKind string
|
||||
|
||||
const (
|
||||
ClientTelemetryMessageDelivery ClientTelemetryKind = "message_delivery"
|
||||
ClientTelemetryReadMetrics ClientTelemetryKind = "read_metrics"
|
||||
ClientTelemetryMusicListen ClientTelemetryKind = "music_listen"
|
||||
)
|
||||
|
||||
func (k ClientTelemetryKind) Valid() bool {
|
||||
switch k {
|
||||
case ClientTelemetryMessageDelivery, ClientTelemetryReadMetrics,
|
||||
ClientTelemetryMusicListen:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ClientTelemetryEvent is operational product telemetry. It is deliberately
|
||||
// isolated from moderation reports/cases and has TTL-based retention.
|
||||
type ClientTelemetryEvent struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Kind ClientTelemetryKind
|
||||
Peer Peer
|
||||
SubjectIDs []int64
|
||||
Payload json.RawMessage
|
||||
Fingerprint [sha256.Size]byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewClientTelemetryEvent(userID int64, kind ClientTelemetryKind, peer Peer, subjectIDs []int64, payload any, createdAt time.Time) (ClientTelemetryEvent, error) {
|
||||
canonicalIDs := append([]int64(nil), subjectIDs...)
|
||||
sort.Slice(canonicalIDs, func(i, j int) bool { return canonicalIDs[i] < canonicalIDs[j] })
|
||||
for i, id := range canonicalIDs {
|
||||
if id <= 0 || (i > 0 && canonicalIDs[i-1] == id) {
|
||||
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||
}
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(raw, &object); err != nil || object == nil {
|
||||
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||
}
|
||||
raw, err = json.Marshal(object)
|
||||
if err != nil || len(raw) > MaxClientTelemetryPayloadBytes {
|
||||
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||
}
|
||||
event := ClientTelemetryEvent{
|
||||
UserID: userID, Kind: kind, Peer: peer,
|
||||
SubjectIDs: canonicalIDs, Payload: raw, CreatedAt: createdAt.UTC(),
|
||||
}
|
||||
fingerprintInput, err := json.Marshal(struct {
|
||||
Version int
|
||||
UserID int64
|
||||
Kind ClientTelemetryKind
|
||||
Peer Peer
|
||||
SubjectIDs []int64
|
||||
Payload json.RawMessage
|
||||
Minute int64
|
||||
}{
|
||||
Version: 1, UserID: event.UserID, Kind: event.Kind, Peer: event.Peer,
|
||||
SubjectIDs: event.SubjectIDs, Payload: event.Payload,
|
||||
Minute: event.CreatedAt.Truncate(time.Minute).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||
}
|
||||
event.Fingerprint = sha256.Sum256(fingerprintInput)
|
||||
if err := event.Validate(); err != nil {
|
||||
return ClientTelemetryEvent{}, err
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (e ClientTelemetryEvent) Validate() error {
|
||||
if e.ID < 0 || e.UserID <= 0 || !e.Kind.Valid() ||
|
||||
len(e.SubjectIDs) == 0 ||
|
||||
len(e.SubjectIDs) > MaxClientTelemetrySubjects ||
|
||||
len(e.Payload) == 0 || len(e.Payload) > MaxClientTelemetryPayloadBytes ||
|
||||
e.Fingerprint == ([sha256.Size]byte{}) || e.CreatedAt.IsZero() {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
if e.Peer.ID == 0 {
|
||||
if e.Peer.Type != "" || e.Kind != ClientTelemetryMusicListen {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
} else if !moderationPeerValid(e.Peer) {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
for i, id := range e.SubjectIDs {
|
||||
if id <= 0 || (i > 0 && e.SubjectIDs[i-1] >= id) {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
}
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(e.Payload, &object); err != nil || object == nil {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
canonical, err := json.Marshal(object)
|
||||
if err != nil || !bytes.Equal(canonical, e.Payload) {
|
||||
return ErrClientTelemetryInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
65
internal/domain/client_telemetry_test.go
Normal file
65
internal/domain/client_telemetry_test.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClientTelemetryEventCanonicalizesSubjectsAndMinuteIdempotency(t *testing.T) {
|
||||
now := time.Unix(1_750_000_000, 0).UTC().Truncate(time.Minute).Add(time.Second)
|
||||
peer := Peer{Type: PeerTypeUser, ID: 22}
|
||||
first, err := NewClientTelemetryEvent(
|
||||
11, ClientTelemetryMessageDelivery, peer, []int64{3, 1, 2},
|
||||
map[string]any{"push": true}, now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry, err := NewClientTelemetryEvent(
|
||||
11, ClientTelemetryMessageDelivery, peer, []int64{2, 3, 1},
|
||||
struct {
|
||||
Push bool `json:"push"`
|
||||
}{Push: true},
|
||||
now.Add(30*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := first.SubjectIDs; len(got) != 3 ||
|
||||
got[0] != 1 || got[1] != 2 || got[2] != 3 {
|
||||
t.Fatalf("canonical subjects = %v", got)
|
||||
}
|
||||
if first.Fingerprint != retry.Fingerprint {
|
||||
t.Fatal("same telemetry inside one minute must have one fingerprint")
|
||||
}
|
||||
nextMinute, err := NewClientTelemetryEvent(
|
||||
11, ClientTelemetryMessageDelivery, peer, []int64{1, 2, 3},
|
||||
map[string]any{"push": true}, now.Add(time.Minute),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nextMinute.Fingerprint == first.Fingerprint {
|
||||
t.Fatal("a new minute bucket must produce a new fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientTelemetryEventRejectsDuplicateSubjectsAndInvalidPeer(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
_, err := NewClientTelemetryEvent(
|
||||
11, ClientTelemetryReadMetrics,
|
||||
Peer{Type: PeerTypeUser, ID: 22},
|
||||
[]int64{1, 1}, map[string]any{"metrics": []int{1}}, now,
|
||||
)
|
||||
if !errors.Is(err, ErrClientTelemetryInvalid) {
|
||||
t.Fatalf("duplicate subjects err=%v", err)
|
||||
}
|
||||
_, err = NewClientTelemetryEvent(
|
||||
11, ClientTelemetryMessageDelivery, Peer{},
|
||||
[]int64{1}, map[string]any{"push": true}, now,
|
||||
)
|
||||
if !errors.Is(err, ErrClientTelemetryInvalid) {
|
||||
t.Fatalf("missing message peer err=%v", err)
|
||||
}
|
||||
}
|
||||
635
internal/domain/collectible_username.go
Normal file
635
internal/domain/collectible_username.go
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Collectible (Fragment-style) usernames.
|
||||
//
|
||||
// A peer holds exactly one editable username -- the slot the client owns through
|
||||
// account.updateUsername / channels.updateUsername -- plus any number of
|
||||
// collectible usernames up to MaxPeerCollectibleUsernames. Both kinds live in
|
||||
// the same global registry, so occupancy checks and username resolution have a
|
||||
// single source of truth.
|
||||
//
|
||||
// The editable slot keeps the service-level 5..32 length rule. Collectible names
|
||||
// are minted by the operator and may be shorter: short names are precisely what
|
||||
// a collectible market distributes. They still have to be syntactically
|
||||
// resolvable by clients, so the character rules are identical.
|
||||
const (
|
||||
MinCollectibleUsernameLength = 4
|
||||
MaxCollectibleUsernameLength = 32
|
||||
// MaxPeerCollectibleUsernames bounds the collectible rows a single peer can
|
||||
// hold. The TL usernames vector is rendered in full by clients, so the bound
|
||||
// keeps both the projection cost and the rendered list finite.
|
||||
MaxPeerCollectibleUsernames = 20
|
||||
// MaxUsernameSortOrder matches the registry CHECK and bounds reorder input.
|
||||
MaxUsernameSortOrder = 1024
|
||||
// MaxCollectibleUsernameURLLength matches the registry CHECK on url.
|
||||
MaxCollectibleUsernameURLLength = 512
|
||||
// MaxCollectibleUsernameReasonLength matches the provenance CHECK on reason.
|
||||
MaxCollectibleUsernameReasonLength = 512
|
||||
// MaxCollectibleUsernameActorLength matches the provenance CHECK on actor.
|
||||
MaxCollectibleUsernameActorLength = 128
|
||||
// MaxCollectibleUsernameCommandKeyLength matches the idempotency CHECK.
|
||||
MaxCollectibleUsernameCommandKeyLength = 128
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrCollectibleUsernameNotFound is returned when no asset backs the name.
|
||||
ErrCollectibleUsernameNotFound = errors.New("collectible username not found")
|
||||
// ErrCollectibleUsernameNotOwned rejects operations that need a live owner.
|
||||
ErrCollectibleUsernameNotOwned = errors.New("collectible username not owned")
|
||||
// ErrCollectibleUsernameBurned rejects any mutation of a burned asset.
|
||||
ErrCollectibleUsernameBurned = errors.New("collectible username burned")
|
||||
// ErrCollectibleUsernameLimit reports the per-peer collectible bound.
|
||||
ErrCollectibleUsernameLimit = errors.New("collectible username limit reached")
|
||||
// ErrCollectibleUsernameStateInvalid rejects an impossible asset shape.
|
||||
ErrCollectibleUsernameStateInvalid = errors.New("collectible username state invalid")
|
||||
// ErrUsernameNotCollectible rejects collectible-only operations on the
|
||||
// editable slot, which the client owns and the operator must not move.
|
||||
ErrUsernameNotCollectible = errors.New("username not collectible")
|
||||
// ErrUsernameNotEditable rejects editable-only operations on a collectible.
|
||||
ErrUsernameNotEditable = errors.New("username not editable")
|
||||
// ErrUsernameOrderInvalid rejects a reorder that is not a permutation of the
|
||||
// peer's current collectible usernames.
|
||||
ErrUsernameOrderInvalid = errors.New("username order invalid")
|
||||
// ErrCollectibleCurrencyInvalid rejects an unsupported purchase currency.
|
||||
ErrCollectibleCurrencyInvalid = errors.New("collectible currency invalid")
|
||||
)
|
||||
|
||||
// Purchase currencies recorded on a collectible asset. XTR is Stars, TON is the
|
||||
// local (non on-chain) TON ledger, USD is a bookkeeping-only fiat record for
|
||||
// assets the operator imported rather than sold.
|
||||
const (
|
||||
CollectibleCurrencyStars = "XTR"
|
||||
CollectibleCurrencyTON = "TON"
|
||||
CollectibleCurrencyUSD = "USD"
|
||||
)
|
||||
|
||||
// CollectibleCryptoCurrencyTON is the only crypto currency the projection
|
||||
// reports, matching the TON ledger the star gift lifecycle already uses.
|
||||
const CollectibleCryptoCurrencyTON = "TON"
|
||||
|
||||
// CollectibleUsernameStatus is the asset lifecycle.
|
||||
//
|
||||
// vault -- minted, held by the operator, attached to no peer.
|
||||
// owned -- attached to a peer and present in that peer's username registry.
|
||||
// burned -- permanently retired; the name is released back to the pool.
|
||||
type CollectibleUsernameStatus string
|
||||
|
||||
const (
|
||||
CollectibleUsernameStatusVault CollectibleUsernameStatus = "vault"
|
||||
CollectibleUsernameStatusOwned CollectibleUsernameStatus = "owned"
|
||||
CollectibleUsernameStatusBurned CollectibleUsernameStatus = "burned"
|
||||
)
|
||||
|
||||
// Valid reports whether the status is one of the three modelled states.
|
||||
func (s CollectibleUsernameStatus) Valid() bool {
|
||||
switch s {
|
||||
case CollectibleUsernameStatusVault, CollectibleUsernameStatusOwned, CollectibleUsernameStatusBurned:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CollectibleUsernameTransferKind is the provenance entry kind.
|
||||
type CollectibleUsernameTransferKind string
|
||||
|
||||
const (
|
||||
// CollectibleUsernameKindMint records the asset entering the vault.
|
||||
CollectibleUsernameKindMint CollectibleUsernameTransferKind = "mint"
|
||||
// CollectibleUsernameKindTransfer records an ownership change, including the
|
||||
// first assignment out of the vault.
|
||||
CollectibleUsernameKindTransfer CollectibleUsernameTransferKind = "transfer"
|
||||
// CollectibleUsernameKindRevoke records an owner losing the asset back to the
|
||||
// vault without the name being released.
|
||||
CollectibleUsernameKindRevoke CollectibleUsernameTransferKind = "revoke"
|
||||
// CollectibleUsernameKindBurn records permanent retirement.
|
||||
CollectibleUsernameKindBurn CollectibleUsernameTransferKind = "burn"
|
||||
)
|
||||
|
||||
// Valid reports whether the kind is modelled.
|
||||
func (k CollectibleUsernameTransferKind) Valid() bool {
|
||||
switch k {
|
||||
case CollectibleUsernameKindMint, CollectibleUsernameKindTransfer, CollectibleUsernameKindRevoke, CollectibleUsernameKindBurn:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Username is one registry row of a peer's username list. It projects directly
|
||||
// onto username#b4073647 (editable/active flags plus the display form).
|
||||
type Username struct {
|
||||
Username string
|
||||
Active bool
|
||||
Editable bool
|
||||
// SortOrder orders collectible rows. The editable row always sorts first
|
||||
// regardless of its stored value, matching client expectations that the
|
||||
// editable username heads the list.
|
||||
SortOrder int
|
||||
// CollectibleID is zero for the editable slot and non-zero for an asset.
|
||||
CollectibleID int64
|
||||
}
|
||||
|
||||
// Collectible reports whether this row is backed by a collectible asset.
|
||||
func (u Username) Collectible() bool { return u.CollectibleID != 0 }
|
||||
|
||||
// CollectibleUsername is the asset behind a collectible username: who holds it,
|
||||
// what was paid, and where the purchase can be verified.
|
||||
type CollectibleUsername struct {
|
||||
ID int64
|
||||
Username string
|
||||
Status CollectibleUsernameStatus
|
||||
// Owner is the zero Peer unless Status is owned.
|
||||
Owner Peer
|
||||
// PurchaseDate, Currency, Amount, CryptoCurrency, CryptoAmount and URL are
|
||||
// the fragment.collectibleInfo payload.
|
||||
PurchaseDate time.Time
|
||||
Currency string
|
||||
Amount int64
|
||||
CryptoCurrency string
|
||||
CryptoAmount int64
|
||||
URL string
|
||||
// OriginalOwner is the first holder and survives transfers and burns.
|
||||
OriginalOwner Peer
|
||||
TransferCount int
|
||||
Version int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CollectibleInfo is the fragment.collectibleInfo projection. PurchaseDate is a
|
||||
// unix timestamp because the TL field is an int date.
|
||||
type CollectibleInfo struct {
|
||||
PurchaseDate int
|
||||
Currency string
|
||||
Amount int64
|
||||
CryptoCurrency string
|
||||
CryptoAmount int64
|
||||
URL string
|
||||
}
|
||||
|
||||
// Info returns the TL-shaped purchase record.
|
||||
func (c CollectibleUsername) Info() CollectibleInfo {
|
||||
date := 0
|
||||
if !c.PurchaseDate.IsZero() {
|
||||
date = int(c.PurchaseDate.Unix())
|
||||
}
|
||||
return CollectibleInfo{
|
||||
PurchaseDate: date,
|
||||
Currency: c.Currency,
|
||||
Amount: c.Amount,
|
||||
CryptoCurrency: c.CryptoCurrency,
|
||||
CryptoAmount: c.CryptoAmount,
|
||||
URL: c.URL,
|
||||
}
|
||||
}
|
||||
|
||||
// Owned reports whether the asset is currently attached to a peer.
|
||||
func (c CollectibleUsername) Owned() bool {
|
||||
return c.Status == CollectibleUsernameStatusOwned && c.Owner.Type != "" && c.Owner.ID > 0
|
||||
}
|
||||
|
||||
// Validate enforces the invariants the registry CHECK constraints encode, so an
|
||||
// in-memory store and PostgreSQL reject the same shapes.
|
||||
func (c CollectibleUsername) Validate() error {
|
||||
if !ValidCollectibleUsername(c.Username) {
|
||||
return ErrUsernameInvalid
|
||||
}
|
||||
if !c.Status.Valid() {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
switch c.Status {
|
||||
case CollectibleUsernameStatusOwned:
|
||||
if !validCollectibleOwner(c.Owner) {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
default:
|
||||
if c.Owner.Type != "" || c.Owner.ID != 0 {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
}
|
||||
if err := ValidateCollectibleAmounts(c.Currency, c.Amount, c.CryptoCurrency, c.CryptoAmount); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(c.URL) > MaxCollectibleUsernameURLLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if c.TransferCount < 0 {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if c.OriginalOwner.Type != "" && !validCollectibleOwner(c.OriginalOwner) {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validCollectibleOwner(peer Peer) bool {
|
||||
switch peer.Type {
|
||||
case PeerTypeUser, PeerTypeChannel:
|
||||
return peer.ID > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateCollectibleAmounts enforces the currency/amount pairing shared by the
|
||||
// registry CHECK constraints: a crypto currency requires a positive amount and
|
||||
// an empty one forbids it.
|
||||
func ValidateCollectibleAmounts(currency string, amount int64, cryptoCurrency string, cryptoAmount int64) error {
|
||||
switch currency {
|
||||
case CollectibleCurrencyStars, CollectibleCurrencyTON, CollectibleCurrencyUSD:
|
||||
default:
|
||||
return ErrCollectibleCurrencyInvalid
|
||||
}
|
||||
if amount < 0 {
|
||||
return ErrCollectibleCurrencyInvalid
|
||||
}
|
||||
switch cryptoCurrency {
|
||||
case "":
|
||||
if cryptoAmount != 0 {
|
||||
return ErrCollectibleCurrencyInvalid
|
||||
}
|
||||
case CollectibleCryptoCurrencyTON:
|
||||
if cryptoAmount <= 0 {
|
||||
return ErrCollectibleCurrencyInvalid
|
||||
}
|
||||
default:
|
||||
return ErrCollectibleCurrencyInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CollectibleUsernameTransfer is one provenance row.
|
||||
type CollectibleUsernameTransfer struct {
|
||||
ID int64
|
||||
CollectibleID int64
|
||||
Kind CollectibleUsernameTransferKind
|
||||
From Peer
|
||||
To Peer
|
||||
Currency string
|
||||
Amount int64
|
||||
Actor string
|
||||
Reason string
|
||||
CommandKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// NormalizeUsername trims whitespace and a leading '@'. It is the shared entry
|
||||
// normalisation for every username surface: RPC, admin API and store.
|
||||
func NormalizeUsername(username string) string {
|
||||
username = strings.TrimSpace(username)
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
return strings.TrimSpace(username)
|
||||
}
|
||||
|
||||
// ValidCollectibleUsername reports whether the name is syntactically usable as a
|
||||
// collectible username. Character rules match the editable slot exactly; only
|
||||
// the minimum length differs.
|
||||
func ValidCollectibleUsername(username string) bool {
|
||||
return validUsernameChars(username, MinCollectibleUsernameLength, MaxCollectibleUsernameLength)
|
||||
}
|
||||
|
||||
func validUsernameChars(username string, minLen, maxLen int) bool {
|
||||
if len(username) < minLen || len(username) > maxLen {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(username); i++ {
|
||||
c := username[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z':
|
||||
case c >= 'A' && c <= 'Z':
|
||||
case c >= '0' && c <= '9':
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
case c == '_':
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SortUsernames returns the projection order clients expect: stored order first,
|
||||
// then the editable slot ahead of a collectible that shares its position, then
|
||||
// the name as a stable tiebreak. The input is not mutated.
|
||||
//
|
||||
// Order beats editability because core.telegram.org/api/fragment makes the first
|
||||
// entry of the vector the peer's primary username, and reorderUsernames is
|
||||
// allowed to move the editable slot out of that position. Editability is only the
|
||||
// tiebreak, which is what keeps a peer that never reordered anything projecting
|
||||
// exactly as before: every such row carries sort_order 0, including the editable
|
||||
// one, so the tiebreak alone decides and the editable slot stays first.
|
||||
func SortUsernames(list []Username) []Username {
|
||||
out := append([]Username(nil), list...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].SortOrder != out[j].SortOrder {
|
||||
return out[i].SortOrder < out[j].SortOrder
|
||||
}
|
||||
if out[i].Editable != out[j].Editable {
|
||||
return out[i].Editable
|
||||
}
|
||||
return strings.ToLower(out[i].Username) < strings.ToLower(out[j].Username)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ActiveUsername returns the name clients treat as the peer's primary username:
|
||||
// the first active entry of the projected vector, which is the active editable
|
||||
// slot until a reorder moves a collectible ahead of it.
|
||||
func ActiveUsername(list []Username) string {
|
||||
for _, item := range SortUsernames(list) {
|
||||
if item.Active && item.Username != "" {
|
||||
return item.Username
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ValidateUsernameReorder checks that order is a valid new ordering of the peer's
|
||||
// usernames.
|
||||
//
|
||||
// The contract is the one clients implement, not a collectible-only one:
|
||||
// core.telegram.org/api/fragment says "all currently active usernames must be
|
||||
// specified", and the editable slot is an active username. Telegram Desktop
|
||||
// therefore sends the whole visible list -- editable slot included -- and a
|
||||
// server that rejects the editable name answers USERNAME_INVALID to a correct
|
||||
// client, which is what made a channel's username editor unusable.
|
||||
//
|
||||
// So: every name in order must belong to the peer, without duplicates, and every
|
||||
// *active* username of the peer must appear. Inactive names are optional, because
|
||||
// they are not part of what the client is showing; when a client does include
|
||||
// them they are positioned like any other name.
|
||||
func ValidateUsernameReorder(current []Username, order []string) error {
|
||||
// The bound is the collectible bound plus the one editable slot, because the
|
||||
// editable name is a legitimate member of the vector.
|
||||
if len(order) > MaxPeerCollectibleUsernames+1 {
|
||||
return ErrUsernameOrderInvalid
|
||||
}
|
||||
owned := make(map[string]struct{}, len(current))
|
||||
active := make(map[string]struct{}, len(current))
|
||||
for _, item := range current {
|
||||
key := strings.ToLower(item.Username)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
owned[key] = struct{}{}
|
||||
if item.Active {
|
||||
active[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(order))
|
||||
for _, name := range order {
|
||||
key := strings.ToLower(NormalizeUsername(name))
|
||||
if key == "" {
|
||||
return ErrUsernameOrderInvalid
|
||||
}
|
||||
if _, ok := owned[key]; !ok {
|
||||
return ErrUsernameOrderInvalid
|
||||
}
|
||||
if _, dup := seen[key]; dup {
|
||||
return ErrUsernameOrderInvalid
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
for key := range active {
|
||||
if _, ok := seen[key]; !ok {
|
||||
return ErrUsernameOrderInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SameUsernameOrder reports whether two lists project the same visible sequence
|
||||
// of names. It is what a reorder reports as "changed", rather than whether the
|
||||
// stored sort_order integers moved: legacy rows numbered the editable slot and
|
||||
// the first collectible both 0, so the very first reorder renumbers rows without
|
||||
// moving anything a client can see, and every peer of the peer would be notified
|
||||
// for nothing.
|
||||
func SameUsernameOrder(a, b []Username) bool {
|
||||
left, right := SortUsernames(a), SortUsernames(b)
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for i := range left {
|
||||
if !strings.EqualFold(left[i].Username, right[i].Username) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ApplyUsernameReorder returns the list with sort orders rewritten to follow
|
||||
// order, the editable slot included: a client is allowed to move its own username
|
||||
// below a collectible one, and the first entry of the vector is what clients show
|
||||
// as the peer's primary username.
|
||||
//
|
||||
// Names absent from order -- only ever inactive ones, since ValidateUsernameReorder
|
||||
// requires every active name -- keep their relative position and are placed after
|
||||
// everything the client ordered, so an inactive name can never displace a visible
|
||||
// one.
|
||||
func ApplyUsernameReorder(current []Username, order []string) ([]Username, error) {
|
||||
if err := ValidateUsernameReorder(current, order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
position := make(map[string]int, len(order))
|
||||
for i, name := range order {
|
||||
position[strings.ToLower(NormalizeUsername(name))] = i
|
||||
}
|
||||
out := append([]Username(nil), current...)
|
||||
// Unordered rows follow the ordered block in their previous projection order.
|
||||
trailing := len(order)
|
||||
for _, item := range SortUsernames(current) {
|
||||
key := strings.ToLower(item.Username)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := position[key]; ok {
|
||||
continue
|
||||
}
|
||||
position[key] = trailing
|
||||
trailing++
|
||||
}
|
||||
if trailing > MaxUsernameSortOrder {
|
||||
return nil, ErrUsernameOrderInvalid
|
||||
}
|
||||
for i := range out {
|
||||
key := strings.ToLower(out[i].Username)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
out[i].SortOrder = position[key]
|
||||
}
|
||||
return SortUsernames(out), nil
|
||||
}
|
||||
|
||||
// ValidateUsernameToggle rejects toggling the editable slot through the
|
||||
// collectible-only method. Every collectible may be inactive at the same time:
|
||||
// ownership and resolvability are separate facts, and inactive assets remain
|
||||
// visible to their owner without resolving publicly.
|
||||
func ValidateUsernameToggle(current []Username, username string, active bool) error {
|
||||
key := strings.ToLower(NormalizeUsername(username))
|
||||
if key == "" {
|
||||
return ErrUsernameInvalid
|
||||
}
|
||||
var target *Username
|
||||
for i := range current {
|
||||
if strings.ToLower(current[i].Username) == key {
|
||||
target = ¤t[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return ErrUsernameNotOccupied
|
||||
}
|
||||
if !target.Collectible() {
|
||||
return ErrUsernameNotCollectible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MintCollectibleUsernameRequest describes an operator minting a new asset,
|
||||
// optionally assigning it to a holder in the same command.
|
||||
type MintCollectibleUsernameRequest struct {
|
||||
Username string
|
||||
// Owner is optional: the zero value mints into the vault.
|
||||
Owner Peer
|
||||
PurchaseDate time.Time
|
||||
Currency string
|
||||
Amount int64
|
||||
CryptoCurrency string
|
||||
CryptoAmount int64
|
||||
URL string
|
||||
Actor string
|
||||
Reason string
|
||||
CommandKey string
|
||||
}
|
||||
|
||||
// Validate normalises nothing and only checks; callers normalise first.
|
||||
func (r MintCollectibleUsernameRequest) Validate() error {
|
||||
if !ValidCollectibleUsername(r.Username) {
|
||||
return ErrUsernameInvalid
|
||||
}
|
||||
if r.Owner.Type != "" && !validCollectibleOwner(r.Owner) {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if err := ValidateCollectibleAmounts(r.Currency, r.Amount, r.CryptoCurrency, r.CryptoAmount); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(r.URL) > MaxCollectibleUsernameURLLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if len(r.Reason) > MaxCollectibleUsernameReasonLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if len(r.Actor) > MaxCollectibleUsernameActorLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if len(r.CommandKey) > MaxCollectibleUsernameCommandKeyLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TransferCollectibleUsernameRequest moves an asset between peers, or out of the
|
||||
// vault when the asset is unowned.
|
||||
type TransferCollectibleUsernameRequest struct {
|
||||
Username string
|
||||
To Peer
|
||||
Actor string
|
||||
Reason string
|
||||
CommandKey string
|
||||
}
|
||||
|
||||
// Validate checks the request shape.
|
||||
func (r TransferCollectibleUsernameRequest) Validate() error {
|
||||
if !ValidCollectibleUsername(r.Username) {
|
||||
return ErrUsernameInvalid
|
||||
}
|
||||
if !validCollectibleOwner(r.To) {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if len(r.Reason) > MaxCollectibleUsernameReasonLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if len(r.Actor) > MaxCollectibleUsernameActorLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if len(r.CommandKey) > MaxCollectibleUsernameCommandKeyLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeCollectibleUsernameRequest returns an asset to the vault, or burns it.
|
||||
type RevokeCollectibleUsernameRequest struct {
|
||||
Username string
|
||||
// Burn retires the asset permanently instead of returning it to the vault.
|
||||
Burn bool
|
||||
Actor string
|
||||
Reason string
|
||||
CommandKey string
|
||||
}
|
||||
|
||||
// Validate checks the request shape.
|
||||
func (r RevokeCollectibleUsernameRequest) Validate() error {
|
||||
if !ValidCollectibleUsername(r.Username) {
|
||||
return ErrUsernameInvalid
|
||||
}
|
||||
if len(r.Reason) > MaxCollectibleUsernameReasonLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if len(r.Actor) > MaxCollectibleUsernameActorLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if len(r.CommandKey) > MaxCollectibleUsernameCommandKeyLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCollectibleUsernameRequest removes an asset outright, releasing its name
|
||||
// and discarding its provenance. Revoke+Burn retires an asset but keeps the
|
||||
// history; this is the escape hatch for a name that was issued by mistake.
|
||||
type DeleteCollectibleUsernameRequest struct {
|
||||
Username string
|
||||
Actor string
|
||||
Reason string
|
||||
CommandKey string
|
||||
}
|
||||
|
||||
// Validate checks the request shape.
|
||||
func (r DeleteCollectibleUsernameRequest) Validate() error {
|
||||
if !ValidCollectibleUsername(r.Username) {
|
||||
return ErrUsernameInvalid
|
||||
}
|
||||
if len(r.Reason) > MaxCollectibleUsernameReasonLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if len(r.Actor) > MaxCollectibleUsernameActorLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if len(r.CommandKey) > MaxCollectibleUsernameCommandKeyLength {
|
||||
return ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CollectibleUsernameFilter bounds an admin listing query.
|
||||
type CollectibleUsernameFilter struct {
|
||||
Status CollectibleUsernameStatus
|
||||
Owner Peer
|
||||
Query string
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
|
@ -69,24 +69,29 @@ func (p Peer) IsSelfUser(userID int64) bool {
|
|||
|
||||
// Dialog 是账号的一条会话摘要。
|
||||
type Dialog struct {
|
||||
Peer Peer
|
||||
ChannelLeft bool
|
||||
FolderID int
|
||||
TopMessage int
|
||||
TopMessageDate int
|
||||
ReadInboxMaxID int
|
||||
ReadOutboxMaxID int
|
||||
UnreadCount int
|
||||
UnreadMentions int
|
||||
UnreadReactions int
|
||||
TTLPeriod int
|
||||
ThemeEmoticon string
|
||||
HasScheduled bool
|
||||
Pinned bool
|
||||
PinnedOrder int
|
||||
UnreadMark bool
|
||||
ViewForumAsMessages bool
|
||||
PeerSettingsBarHidden bool
|
||||
Peer Peer
|
||||
ChannelLeft bool
|
||||
FolderID int
|
||||
TopMessage int
|
||||
TopMessageDate int
|
||||
// HistoryClearAnchorID/Date are internal owner-view projection metadata.
|
||||
// They are not TL dialog fields; dialog response assembly uses them to
|
||||
// materialize the matching messageActionHistoryClear top payload.
|
||||
HistoryClearAnchorID int
|
||||
HistoryClearAnchorDate int
|
||||
ReadInboxMaxID int
|
||||
ReadOutboxMaxID int
|
||||
UnreadCount int
|
||||
UnreadMentions int
|
||||
UnreadReactions int
|
||||
TTLPeriod int
|
||||
ThemeEmoticon string
|
||||
HasScheduled bool
|
||||
Pinned bool
|
||||
PinnedOrder int
|
||||
UnreadMark bool
|
||||
ViewForumAsMessages bool
|
||||
PeerSettingsBarHidden bool
|
||||
// Pts 是 channel peer 当前 channel pts;客户端用 dialog.pts 初始化本地
|
||||
// channel 序列并决定 getChannelDifference 起点,channel dialog 必填。
|
||||
Pts int
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrLangPackInvalid 表示客户端请求的语言包目录不存在。
|
||||
ErrLangPackInvalid = errors.New("lang pack invalid")
|
||||
// ErrLangCodeNotSupported 表示语言包目录存在,但其中没有请求的语言码。
|
||||
ErrLangCodeNotSupported = errors.New("lang code not supported")
|
||||
)
|
||||
|
||||
// LangPack 是一份客户端语言包的查询结果。
|
||||
type LangPack struct {
|
||||
LangPack string
|
||||
|
|
|
|||
|
|
@ -418,8 +418,23 @@ const (
|
|||
MessageMediaKindTodo MessageMediaKind = "todo"
|
||||
MessageMediaKindStory MessageMediaKind = "story"
|
||||
MessageMediaKindWebPage MessageMediaKind = "web_page"
|
||||
MessageMediaKindGiveaway MessageMediaKind = "giveaway"
|
||||
)
|
||||
|
||||
// MessageGiveaway is the immutable launch-card snapshot shown in the boost
|
||||
// peer. Channels contains the boost peer first followed by any additional
|
||||
// channels users must join. A results card is a separate lifecycle message.
|
||||
type MessageGiveaway struct {
|
||||
OnlyNewSubscribers bool `json:"only_new_subscribers,omitempty"`
|
||||
WinnersAreVisible bool `json:"winners_are_visible,omitempty"`
|
||||
Channels []int64 `json:"channels"`
|
||||
CountriesISO2 []string `json:"countries_iso2,omitempty"`
|
||||
PrizeDescription string `json:"prize_description,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
Stars int64 `json:"stars"`
|
||||
UntilDate int `json:"until_date"`
|
||||
}
|
||||
|
||||
// MessageTodoItem 是清单中的一项(id 为列表内唯一正整数,客户端分配)。
|
||||
type MessageTodoItem struct {
|
||||
ID int `json:"id"`
|
||||
|
|
@ -537,6 +552,11 @@ type MessageServiceActionKind string
|
|||
|
||||
const (
|
||||
MessageServiceActionSuggestProfilePhoto MessageServiceActionKind = "suggest_profile_photo"
|
||||
// MessageServiceActionHistoryClear 映射 messageActionHistoryClear。私聊
|
||||
// messages.deleteHistory(just_clear) 复用清理开始时的 top box id,把它
|
||||
// 原位转换成 owner-local 服务消息,使 getDialogs/getHistory 在冷启动时
|
||||
// 仍能从真实 top message 重建会话。
|
||||
MessageServiceActionHistoryClear MessageServiceActionKind = "history_clear"
|
||||
// MessageServiceActionPinMessage 映射 messageActionPinMessage:非
|
||||
// pm_oneside 私聊置顶生成的服务消息,被置顶消息经 reply_to 指向。
|
||||
MessageServiceActionPinMessage MessageServiceActionKind = "pin_message"
|
||||
|
|
@ -558,9 +578,16 @@ const (
|
|||
// MessageServiceActionSetChatTheme 映射 messageActionSetChatTheme:
|
||||
// 私聊双方共享的 chat theme token 变更。
|
||||
MessageServiceActionSetChatTheme MessageServiceActionKind = "set_chat_theme"
|
||||
// MessageServiceActionNoForwardsToggle / Request 映射私聊内容保护的
|
||||
// 状态切换与关闭请求。会话级保护不能写入普通消息的 NoForwards 字段。
|
||||
MessageServiceActionNoForwardsToggle MessageServiceActionKind = "no_forwards_toggle"
|
||||
MessageServiceActionNoForwardsRequest MessageServiceActionKind = "no_forwards_request"
|
||||
// MessageServiceActionStarGift 映射 messageActionStarGift:收到一份 Star 礼物。
|
||||
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
|
||||
MessageServiceActionStarGift MessageServiceActionKind = "star_gift"
|
||||
// MessageServiceActionGiftStars maps messageActionGiftStars: fiat-purchased
|
||||
// Stars credited directly to a friend, distinct from collectible Star Gifts.
|
||||
MessageServiceActionGiftStars MessageServiceActionKind = "gift_stars"
|
||||
// MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The
|
||||
// immutable collectible snapshot is carried by the service message so an
|
||||
// exact replay/difference never depends on mutable catalog state.
|
||||
|
|
@ -629,6 +656,15 @@ type MessageRequestedPeerDetails struct {
|
|||
Photo *Photo `json:"photo,omitempty"`
|
||||
}
|
||||
|
||||
// MessageNoForwardsAction 是私聊内容保护 service action 的协议中立载荷。
|
||||
// ExpiresAt 只用于 request 的读取时绝对过期投影;toggle 保持为 0。
|
||||
type MessageNoForwardsAction struct {
|
||||
PrevValue bool `json:"prev_value"`
|
||||
NewValue bool `json:"new_value"`
|
||||
Expired bool `json:"expired,omitempty"`
|
||||
ExpiresAt int `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// MessageServiceAction 是私聊服务消息动作的协议中立表示。
|
||||
type MessageServiceAction struct {
|
||||
Kind MessageServiceActionKind `json:"kind"`
|
||||
|
|
@ -639,12 +675,26 @@ type MessageServiceAction struct {
|
|||
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
|
||||
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
|
||||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||
NoForwards *MessageNoForwardsAction `json:"no_forwards,omitempty"`
|
||||
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
||||
GiftStars *MessageGiftStarsAction `json:"gift_stars,omitempty"`
|
||||
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
|
||||
StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"`
|
||||
StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,omitempty"`
|
||||
}
|
||||
|
||||
// MessageGiftStarsAction is the immutable service-message projection. The
|
||||
// recipient-only BalanceAfter field is not encoded in messageActionGiftStars;
|
||||
// it lets online push and offline difference attach the matching non-PTS
|
||||
// updateStarsBalance without querying mutable current state.
|
||||
type MessageGiftStarsAction struct {
|
||||
Currency string `json:"currency"`
|
||||
Amount int64 `json:"amount"`
|
||||
Stars int64 `json:"stars"`
|
||||
TransactionID string `json:"transaction_id,omitempty"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
}
|
||||
|
||||
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
||||
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。
|
||||
type MessageStarGiftAction struct {
|
||||
|
|
@ -730,6 +780,7 @@ type MessageMedia struct {
|
|||
Todo *MessageTodo `json:"todo,omitempty"`
|
||||
Story *MessageStory `json:"story,omitempty"`
|
||||
WebPage *MessageWebPage `json:"web_page,omitempty"`
|
||||
Giveaway *MessageGiveaway `json:"giveaway,omitempty"`
|
||||
Spoiler bool `json:"spoiler,omitempty"`
|
||||
TTLSeconds int `json:"ttl_seconds,omitempty"`
|
||||
Nopremium bool `json:"nopremium,omitempty"`
|
||||
|
|
|
|||
|
|
@ -165,6 +165,37 @@ type Message struct {
|
|||
SavedPeer Peer
|
||||
}
|
||||
|
||||
// NewHistoryClearMessage 把一个 owner 视角的现有 box 原位投影为
|
||||
// messageActionHistoryClear 服务消息。调用方只传递仍需保留的身份字段;
|
||||
// 其余正文、媒体、reply、reaction、TTL、pin 等载荷全部按不变量清空。
|
||||
func NewHistoryClearMessage(ownerUserID int64, peer Peer, boxID int, uid int64, date, pts int) Message {
|
||||
self := Peer{Type: PeerTypeUser, ID: ownerUserID}
|
||||
return Message{
|
||||
ID: boxID,
|
||||
UID: uid,
|
||||
OwnerUserID: ownerUserID,
|
||||
Peer: peer,
|
||||
From: self,
|
||||
Date: date,
|
||||
Out: true,
|
||||
Pts: pts,
|
||||
Media: &MessageMedia{
|
||||
Kind: MessageMediaKindService,
|
||||
ServiceAction: &MessageServiceAction{
|
||||
Kind: MessageServiceActionHistoryClear,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IsHistoryClearServiceMessage 报告该 box 是否已经是清空历史锚点。
|
||||
func IsHistoryClearServiceMessage(msg Message) bool {
|
||||
return msg.Media != nil &&
|
||||
msg.Media.Kind == MessageMediaKindService &&
|
||||
msg.Media.ServiceAction != nil &&
|
||||
msg.Media.ServiceAction.Kind == MessageServiceActionHistoryClear
|
||||
}
|
||||
|
||||
// MessageRichMessage 是 Layer 228 富文本消息(richMessage)的协议中立快照:一组 IV
|
||||
// PageBlock(Blocks)+ 内嵌已解析的 Photos/Documents。
|
||||
//
|
||||
|
|
@ -232,6 +263,8 @@ type MessageFilter struct {
|
|||
Query string
|
||||
OffsetID int
|
||||
OffsetDate int
|
||||
MinDate int
|
||||
MaxDate int
|
||||
AddOffset int
|
||||
Limit int
|
||||
MaxID int
|
||||
|
|
@ -245,6 +278,9 @@ type MessageFilter struct {
|
|||
// SavedPeer 非零时仅返回 self-chat 中该 saved 子会话的消息
|
||||
// (messages.getSavedHistory);Peer 必须同时是 self。
|
||||
SavedPeer Peer
|
||||
// SavedReactions 非空时仅返回至少带其中一个 tag 的 Saved Messages。
|
||||
// 仅 messages.search(peer=self) 使用;普通私聊 reaction 不参与匹配。
|
||||
SavedReactions []MessageReaction
|
||||
// PeerIDs restricts a global private search to these user peers. Empty is a
|
||||
// valid restricted set, so RestrictPeerIDs carries presence separately.
|
||||
PeerIDs []int64
|
||||
|
|
@ -402,6 +438,47 @@ type ForwardPrivateMessagesResult struct {
|
|||
ReplayDeleteEvents []*UpdateEvent
|
||||
}
|
||||
|
||||
const PrivateNoForwardsRequestExpirePeriod = 24 * 60 * 60
|
||||
|
||||
// PrivateNoForwardsState 是一对普通用户唯一的内容保护权威。
|
||||
// EnabledByUserID 为 0 或参与者之一;非零时双方共享的会话均受保护。
|
||||
type PrivateNoForwardsState struct {
|
||||
UserLowID int64
|
||||
UserHighID int64
|
||||
EnabledByUserID int64
|
||||
}
|
||||
|
||||
func (s PrivateNoForwardsState) Enabled() bool {
|
||||
return s.EnabledByUserID != 0
|
||||
}
|
||||
|
||||
func (s PrivateNoForwardsState) ForViewer(viewerUserID int64) (myEnabled, peerEnabled bool) {
|
||||
if s.EnabledByUserID == 0 {
|
||||
return false, false
|
||||
}
|
||||
return s.EnabledByUserID == viewerUserID, s.EnabledByUserID != viewerUserID
|
||||
}
|
||||
|
||||
// TogglePrivateNoForwardsRequest 是私聊内容保护的原子状态+服务消息命令。
|
||||
type TogglePrivateNoForwardsRequest struct {
|
||||
ActorUserID int64
|
||||
PeerUserID int64
|
||||
Enabled bool
|
||||
RequestMsgID int
|
||||
RandomID int64
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// TogglePrivateNoForwardsResult 同时返回提交后的权威状态与本次真实服务消息。
|
||||
// Changed=false 且 Send 为空表示官方定义的 no-op。
|
||||
type TogglePrivateNoForwardsResult struct {
|
||||
State PrivateNoForwardsState
|
||||
Changed bool
|
||||
Send SendPrivateTextResult
|
||||
}
|
||||
|
||||
// ReadHistoryRequest 是账号视角的 messages.readHistory 命令。
|
||||
type ReadHistoryRequest struct {
|
||||
OwnerUserID int64
|
||||
|
|
@ -547,7 +624,23 @@ type DeleteHistoryRequest struct {
|
|||
type DeletedMessagesForUser struct {
|
||||
UserID int64
|
||||
MessageIDs []int
|
||||
Event UpdateEvent
|
||||
// Event 保留单一 delete_messages 事件,供普通 deleteMessages 路径与
|
||||
// replay receipt 使用;just_clear 还会在 Events 中携带 read/edit 事件。
|
||||
Event UpdateEvent
|
||||
Events []UpdateEvent
|
||||
// Pts/PtsCount 是本次 method 对该 owner 的 affected watermark 汇总。
|
||||
// 普通删除等于 Event;just_clear 等于最后一条真实 update 的 pts 与
|
||||
// delete/read/edit 三段 pts_count 之和。
|
||||
Pts int
|
||||
PtsCount int
|
||||
}
|
||||
|
||||
// AffectedPts 返回 messages.Affected* 应使用的最终 PTS 与本次总增量。
|
||||
func (d DeletedMessagesForUser) AffectedPts() (int, int) {
|
||||
if d.Pts != 0 {
|
||||
return d.Pts, d.PtsCount
|
||||
}
|
||||
return d.Event.Pts, d.Event.PtsCount
|
||||
}
|
||||
|
||||
// DeleteMessagesResult 描述消息删除后的 owner 维度结果。
|
||||
|
|
@ -570,7 +663,8 @@ func (r DeleteMessagesResult) Self() DeletedMessagesForUser {
|
|||
// Changed 表示本次删除是否实际影响了任何 owner 视角。
|
||||
func (r DeleteMessagesResult) Changed() bool {
|
||||
for _, item := range r.Deleted {
|
||||
if len(item.MessageIDs) > 0 {
|
||||
pts, _ := item.AffectedPts()
|
||||
if len(item.MessageIDs) > 0 || pts > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ var (
|
|||
ErrLoginCodeDeliveryCommitAmbiguous = errors.New("login code delivery commit ambiguous")
|
||||
ErrReplyMessageIDInvalid = errors.New("reply message id invalid")
|
||||
ErrChatForwardsRestricted = errors.New("chat forwards restricted")
|
||||
ErrNoForwardsRequestExpired = errors.New("no forwards request expired")
|
||||
// ErrPinnedSavedDialogsTooMuch 映射 PINNED_TOO_MUCH:收藏夹子会话置顶
|
||||
// 数量达到 MaxPinnedSavedDialogs 上限。
|
||||
ErrPinnedSavedDialogsTooMuch = errors.New("pinned saved dialogs too much")
|
||||
|
|
|
|||
486
internal/domain/moderation.go
Normal file
486
internal/domain/moderation.go
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
ModerationTaxonomyVersion = 1
|
||||
MaxModerationReportItems = 100
|
||||
MaxModerationMediaHolds = 1000
|
||||
MaxModerationOptionBytes = 32
|
||||
MaxModerationCommentRunes = 512
|
||||
MaxModerationEvidenceBytes = 1 << 20
|
||||
MaxModerationTotalEvidenceBytes = 4 << 20
|
||||
MaxModerationMediaStorageKeyBytes = 512
|
||||
MaxModerationReportsPerHour = 20
|
||||
MaxModerationReportsPerDay = 100
|
||||
)
|
||||
|
||||
var (
|
||||
ErrModerationReportInvalid = errors.New("moderation report invalid")
|
||||
ErrModerationReportNotFound = errors.New("moderation report not found")
|
||||
ErrModerationCaseInvalid = errors.New("moderation case invalid")
|
||||
ErrModerationCaseNotFound = errors.New("moderation case not found")
|
||||
ErrModerationCaseConflict = errors.New("moderation case conflict")
|
||||
ErrModerationActionInvalid = errors.New("moderation action invalid")
|
||||
ErrModerationActionConflict = errors.New("moderation action conflict")
|
||||
ErrModerationPermissionDenied = errors.New("moderation permission denied")
|
||||
ErrModerationRateLimited = errors.New("moderation rate limited")
|
||||
ErrModerationEvidenceNotFound = errors.New("moderation evidence not found")
|
||||
ErrModerationDecisionNotFound = errors.New("moderation decision not found")
|
||||
ErrModerationImpressionExpired = errors.New("moderation impression expired")
|
||||
ErrModerationAppealLinkInvalid = errors.New("moderation appeal link invalid")
|
||||
)
|
||||
|
||||
// ModerationReportSource identifies the client RPC and evidence admission path.
|
||||
// Operational telemetry and authentication delivery diagnostics deliberately do
|
||||
// not use this type or the moderation report tables.
|
||||
type ModerationReportSource string
|
||||
|
||||
const (
|
||||
ModerationSourceAccountPeer ModerationReportSource = "account_peer"
|
||||
ModerationSourceProfilePhoto ModerationReportSource = "profile_photo"
|
||||
ModerationSourceMessagesSpam ModerationReportSource = "messages_spam"
|
||||
ModerationSourceMessages ModerationReportSource = "messages"
|
||||
ModerationSourceEncryptedSpam ModerationReportSource = "encrypted_spam"
|
||||
ModerationSourceReaction ModerationReportSource = "reaction"
|
||||
ModerationSourceChannelSpam ModerationReportSource = "channel_spam"
|
||||
ModerationSourceStory ModerationReportSource = "story"
|
||||
ModerationSourceEphemeral ModerationReportSource = "ephemeral"
|
||||
ModerationSourceSponsored ModerationReportSource = "sponsored"
|
||||
ModerationSourceAntiSpamFalsePositive ModerationReportSource = "antispam_false_positive"
|
||||
)
|
||||
|
||||
func (s ModerationReportSource) Valid() bool {
|
||||
switch s {
|
||||
case ModerationSourceAccountPeer, ModerationSourceProfilePhoto,
|
||||
ModerationSourceMessagesSpam, ModerationSourceMessages,
|
||||
ModerationSourceEncryptedSpam, ModerationSourceReaction,
|
||||
ModerationSourceChannelSpam, ModerationSourceStory,
|
||||
ModerationSourceEphemeral, ModerationSourceSponsored,
|
||||
ModerationSourceAntiSpamFalsePositive:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ModerationReason is the canonical domain taxonomy shared by ReportReason
|
||||
// constructors and the opaque multi-step report option flow.
|
||||
type ModerationReason string
|
||||
|
||||
const (
|
||||
ModerationReasonSpam ModerationReason = "spam"
|
||||
ModerationReasonViolence ModerationReason = "violence"
|
||||
ModerationReasonPornography ModerationReason = "pornography"
|
||||
ModerationReasonChildAbuse ModerationReason = "child_abuse"
|
||||
ModerationReasonOther ModerationReason = "other"
|
||||
ModerationReasonCopyright ModerationReason = "copyright"
|
||||
ModerationReasonGeoIrrelevant ModerationReason = "geo_irrelevant"
|
||||
ModerationReasonFake ModerationReason = "fake"
|
||||
ModerationReasonIllegalDrugs ModerationReason = "illegal_drugs"
|
||||
ModerationReasonPersonalDetails ModerationReason = "personal_details"
|
||||
)
|
||||
|
||||
func (r ModerationReason) Valid() bool {
|
||||
switch r {
|
||||
case ModerationReasonSpam, ModerationReasonViolence,
|
||||
ModerationReasonPornography, ModerationReasonChildAbuse,
|
||||
ModerationReasonOther, ModerationReasonCopyright,
|
||||
ModerationReasonGeoIrrelevant, ModerationReasonFake,
|
||||
ModerationReasonIllegalDrugs, ModerationReasonPersonalDetails:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationReportItemKind string
|
||||
|
||||
const (
|
||||
ModerationItemPeer ModerationReportItemKind = "peer"
|
||||
ModerationItemMessage ModerationReportItemKind = "message"
|
||||
ModerationItemProfilePhoto ModerationReportItemKind = "profile_photo"
|
||||
ModerationItemReaction ModerationReportItemKind = "reaction"
|
||||
ModerationItemStory ModerationReportItemKind = "story"
|
||||
ModerationItemEncryptedChat ModerationReportItemKind = "encrypted_chat"
|
||||
ModerationItemEphemeral ModerationReportItemKind = "ephemeral"
|
||||
ModerationItemSponsored ModerationReportItemKind = "sponsored"
|
||||
ModerationItemAntiSpamDecision ModerationReportItemKind = "antispam_decision"
|
||||
)
|
||||
|
||||
func (k ModerationReportItemKind) Valid() bool {
|
||||
switch k {
|
||||
case ModerationItemPeer, ModerationItemMessage,
|
||||
ModerationItemProfilePhoto, ModerationItemReaction,
|
||||
ModerationItemStory, ModerationItemEncryptedChat,
|
||||
ModerationItemEphemeral, ModerationItemSponsored,
|
||||
ModerationItemAntiSpamDecision:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationMediaKind string
|
||||
|
||||
const (
|
||||
ModerationMediaPhoto ModerationMediaKind = "photo"
|
||||
ModerationMediaDocument ModerationMediaKind = "document"
|
||||
ModerationMediaBlob ModerationMediaKind = "blob"
|
||||
)
|
||||
|
||||
func (k ModerationMediaKind) Valid() bool {
|
||||
switch k {
|
||||
case ModerationMediaPhoto, ModerationMediaDocument, ModerationMediaBlob:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ModerationReportItem is a stable reference plus a privacy-bounded evidence
|
||||
// snapshot. Evidence must be a versioned JSON object produced by the owning app
|
||||
// service; moderation never repairs malformed historical snapshots on read.
|
||||
type ModerationReportItem struct {
|
||||
Kind ModerationReportItemKind
|
||||
Peer Peer
|
||||
ItemID int64
|
||||
SecondaryID int64
|
||||
AuthorUserID int64
|
||||
EvidenceSchemaVersion int
|
||||
Evidence json.RawMessage
|
||||
EvidenceHash [sha256.Size]byte
|
||||
}
|
||||
|
||||
type ModerationMediaHold struct {
|
||||
ItemIndex int
|
||||
Kind ModerationMediaKind
|
||||
StorageKey string
|
||||
}
|
||||
|
||||
// ModerationReport is immutable after acceptance. ID is assigned by the store;
|
||||
// Fingerprint is a deterministic SHA-256 of the immutable client intent and
|
||||
// evidence identity, excluding CreatedAt.
|
||||
type ModerationReport struct {
|
||||
ID int64
|
||||
ReporterUserID int64
|
||||
Source ModerationReportSource
|
||||
Target Peer
|
||||
Reason ModerationReason
|
||||
Option string
|
||||
Comment string
|
||||
CommentHash [sha256.Size]byte
|
||||
Fingerprint [sha256.Size]byte
|
||||
TaxonomyVersion int
|
||||
Items []ModerationReportItem
|
||||
MediaHolds []ModerationMediaHold
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationReportDraft struct {
|
||||
ReporterUserID int64
|
||||
Source ModerationReportSource
|
||||
Target Peer
|
||||
Reason ModerationReason
|
||||
Option string
|
||||
Comment string
|
||||
TaxonomyVersion int
|
||||
Items []ModerationReportItem
|
||||
MediaHolds []ModerationMediaHold
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationMessageReportRequest struct {
|
||||
ReporterUserID int64
|
||||
Target Peer
|
||||
MessageIDs []int
|
||||
Reason ModerationReason
|
||||
Option string
|
||||
Comment string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationStoryReportRequest struct {
|
||||
ReporterUserID int64
|
||||
Target Peer
|
||||
StoryIDs []int
|
||||
Reason ModerationReason
|
||||
Option string
|
||||
Comment string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationProfilePhotoReportRequest struct {
|
||||
ReporterUserID int64
|
||||
Target Peer
|
||||
PhotoID int64
|
||||
AccessHash int64
|
||||
FileReference []byte
|
||||
Reason ModerationReason
|
||||
Comment string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationChannelSpamReportRequest struct {
|
||||
ReporterUserID int64
|
||||
ChannelID int64
|
||||
ParticipantUserID int64
|
||||
MessageIDs []int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationReactionReportRequest struct {
|
||||
ReporterUserID int64
|
||||
Target Peer
|
||||
MessageID int
|
||||
ReactorUserID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// NewModerationReport canonicalizes item order and computes all content hashes.
|
||||
// Callers must pass snapshots, not mutable domain objects.
|
||||
func NewModerationReport(draft ModerationReportDraft) (ModerationReport, error) {
|
||||
originalItems := cloneModerationItems(draft.Items)
|
||||
report := ModerationReport{
|
||||
ReporterUserID: draft.ReporterUserID,
|
||||
Source: draft.Source,
|
||||
Target: draft.Target,
|
||||
Reason: draft.Reason,
|
||||
Option: draft.Option,
|
||||
Comment: draft.Comment,
|
||||
TaxonomyVersion: draft.TaxonomyVersion,
|
||||
Items: cloneModerationItems(originalItems),
|
||||
MediaHolds: append([]ModerationMediaHold(nil), draft.MediaHolds...),
|
||||
CreatedAt: draft.CreatedAt,
|
||||
}
|
||||
if report.TaxonomyVersion == 0 {
|
||||
report.TaxonomyVersion = ModerationTaxonomyVersion
|
||||
}
|
||||
report.CommentHash = sha256.Sum256([]byte(report.Comment))
|
||||
for i := range report.Items {
|
||||
evidence, err := CanonicalModerationEvidence(report.Items[i].Evidence)
|
||||
if err != nil {
|
||||
return ModerationReport{}, err
|
||||
}
|
||||
report.Items[i].Evidence = evidence
|
||||
report.Items[i].EvidenceHash = sha256.Sum256(report.Items[i].Evidence)
|
||||
}
|
||||
sort.Slice(report.Items, func(i, j int) bool {
|
||||
return moderationItemLess(report.Items[i], report.Items[j])
|
||||
})
|
||||
canonicalIndexes := make(map[moderationItemIdentity]int, len(report.Items))
|
||||
for i, item := range report.Items {
|
||||
canonicalIndexes[moderationItemIdentityOf(item)] = i
|
||||
}
|
||||
for i := range report.MediaHolds {
|
||||
oldIndex := report.MediaHolds[i].ItemIndex
|
||||
if oldIndex >= 0 && oldIndex < len(originalItems) {
|
||||
if canonicalIndex, ok := canonicalIndexes[moderationItemIdentityOf(originalItems[oldIndex])]; ok {
|
||||
report.MediaHolds[i].ItemIndex = canonicalIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(report.MediaHolds, func(i, j int) bool {
|
||||
a, b := report.MediaHolds[i], report.MediaHolds[j]
|
||||
if a.ItemIndex != b.ItemIndex {
|
||||
return a.ItemIndex < b.ItemIndex
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
return a.StorageKey < b.StorageKey
|
||||
})
|
||||
fingerprint, err := moderationReportFingerprint(report)
|
||||
if err != nil {
|
||||
return ModerationReport{}, err
|
||||
}
|
||||
report.Fingerprint = fingerprint
|
||||
if err := report.Validate(); err != nil {
|
||||
return ModerationReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (r ModerationReport) Validate() error {
|
||||
if r.ID < 0 || r.ReporterUserID <= 0 || !r.Source.Valid() ||
|
||||
!moderationPeerValid(r.Target) || !r.Reason.Valid() ||
|
||||
r.Option == "" || len(r.Option) > MaxModerationOptionBytes ||
|
||||
!utf8.ValidString(r.Option) || !utf8.ValidString(r.Comment) ||
|
||||
utf8.RuneCountInString(r.Comment) > MaxModerationCommentRunes ||
|
||||
r.TaxonomyVersion <= 0 || r.TaxonomyVersion > 32767 ||
|
||||
len(r.Items) == 0 || len(r.Items) > MaxModerationReportItems ||
|
||||
len(r.MediaHolds) > MaxModerationMediaHolds || r.CreatedAt.IsZero() ||
|
||||
r.CommentHash != sha256.Sum256([]byte(r.Comment)) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
totalEvidence := 0
|
||||
seenItems := make(map[moderationItemIdentity]struct{}, len(r.Items))
|
||||
for i, item := range r.Items {
|
||||
if !item.Kind.Valid() || !moderationPeerValid(item.Peer) ||
|
||||
item.ItemID <= 0 || item.SecondaryID < 0 || item.AuthorUserID < 0 ||
|
||||
item.EvidenceSchemaVersion <= 0 || item.EvidenceSchemaVersion > 32767 ||
|
||||
len(item.Evidence) == 0 || len(item.Evidence) > MaxModerationEvidenceBytes ||
|
||||
!json.Valid(item.Evidence) ||
|
||||
item.EvidenceHash != sha256.Sum256(item.Evidence) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
canonical, err := CanonicalModerationEvidence(item.Evidence)
|
||||
if err != nil || !bytes.Equal(canonical, item.Evidence) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
if i > 0 && moderationItemLess(item, r.Items[i-1]) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
identity := moderationItemIdentityOf(item)
|
||||
if _, duplicate := seenItems[identity]; duplicate {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
seenItems[identity] = struct{}{}
|
||||
totalEvidence += len(item.Evidence)
|
||||
if totalEvidence > MaxModerationTotalEvidenceBytes {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
}
|
||||
seenHolds := make(map[ModerationMediaHold]struct{}, len(r.MediaHolds))
|
||||
for _, hold := range r.MediaHolds {
|
||||
if hold.ItemIndex < 0 || hold.ItemIndex >= len(r.Items) ||
|
||||
!hold.Kind.Valid() || hold.StorageKey == "" ||
|
||||
len(hold.StorageKey) > MaxModerationMediaStorageKeyBytes ||
|
||||
!utf8.ValidString(hold.StorageKey) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
if _, duplicate := seenHolds[hold]; duplicate {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
seenHolds[hold] = struct{}{}
|
||||
}
|
||||
fingerprint, err := moderationReportFingerprint(r)
|
||||
if err != nil || fingerprint != r.Fingerprint {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type moderationItemIdentity struct {
|
||||
Kind ModerationReportItemKind
|
||||
PeerType PeerType
|
||||
PeerID int64
|
||||
ItemID int64
|
||||
SecondaryID int64
|
||||
}
|
||||
|
||||
func moderationItemIdentityOf(item ModerationReportItem) moderationItemIdentity {
|
||||
return moderationItemIdentity{
|
||||
Kind: item.Kind, PeerType: item.Peer.Type, PeerID: item.Peer.ID,
|
||||
ItemID: item.ItemID, SecondaryID: item.SecondaryID,
|
||||
}
|
||||
}
|
||||
|
||||
func moderationItemLess(a, b ModerationReportItem) bool {
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
if a.Peer.Type != b.Peer.Type {
|
||||
return a.Peer.Type < b.Peer.Type
|
||||
}
|
||||
if a.Peer.ID != b.Peer.ID {
|
||||
return a.Peer.ID < b.Peer.ID
|
||||
}
|
||||
if a.ItemID != b.ItemID {
|
||||
return a.ItemID < b.ItemID
|
||||
}
|
||||
return a.SecondaryID < b.SecondaryID
|
||||
}
|
||||
|
||||
func moderationPeerValid(peer Peer) bool {
|
||||
return peer.ID > 0 && (peer.Type == PeerTypeUser || peer.Type == PeerTypeChannel)
|
||||
}
|
||||
|
||||
// CanonicalModerationEvidence normalizes a JSON object with Go's deterministic
|
||||
// map-key ordering. This keeps evidence hashes stable after PostgreSQL jsonb
|
||||
// normalizes whitespace and object key order.
|
||||
func CanonicalModerationEvidence(raw json.RawMessage) (json.RawMessage, error) {
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, ErrModerationReportInvalid
|
||||
}
|
||||
if _, ok := value.(map[string]any); !ok {
|
||||
return nil, ErrModerationReportInvalid
|
||||
}
|
||||
canonical, err := json.Marshal(value)
|
||||
if err != nil || len(canonical) == 0 || len(canonical) > MaxModerationEvidenceBytes {
|
||||
return nil, ErrModerationReportInvalid
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
type moderationFingerprintItem struct {
|
||||
Kind ModerationReportItemKind `json:"kind"`
|
||||
PeerType PeerType `json:"peer_type"`
|
||||
PeerID int64 `json:"peer_id"`
|
||||
ItemID int64 `json:"item_id"`
|
||||
SecondaryID int64 `json:"secondary_id"`
|
||||
AuthorUserID int64 `json:"author_user_id"`
|
||||
EvidenceSchemaVersion int `json:"evidence_schema_version"`
|
||||
EvidenceHash [sha256.Size]byte `json:"evidence_hash"`
|
||||
}
|
||||
|
||||
type moderationFingerprintPayload struct {
|
||||
Version int `json:"version"`
|
||||
ReporterUserID int64 `json:"reporter_user_id"`
|
||||
Source ModerationReportSource `json:"source"`
|
||||
TargetType PeerType `json:"target_type"`
|
||||
TargetID int64 `json:"target_id"`
|
||||
Reason ModerationReason `json:"reason"`
|
||||
Option string `json:"option"`
|
||||
CommentHash [sha256.Size]byte `json:"comment_hash"`
|
||||
TaxonomyVersion int `json:"taxonomy_version"`
|
||||
Items []moderationFingerprintItem `json:"items"`
|
||||
}
|
||||
|
||||
func moderationReportFingerprint(report ModerationReport) ([sha256.Size]byte, error) {
|
||||
items := make([]moderationFingerprintItem, 0, len(report.Items))
|
||||
for _, item := range report.Items {
|
||||
items = append(items, moderationFingerprintItem{
|
||||
Kind: item.Kind, PeerType: item.Peer.Type, PeerID: item.Peer.ID,
|
||||
ItemID: item.ItemID, SecondaryID: item.SecondaryID,
|
||||
AuthorUserID: item.AuthorUserID,
|
||||
EvidenceSchemaVersion: item.EvidenceSchemaVersion,
|
||||
EvidenceHash: item.EvidenceHash,
|
||||
})
|
||||
}
|
||||
raw, err := json.Marshal(moderationFingerprintPayload{
|
||||
Version: 1, ReporterUserID: report.ReporterUserID, Source: report.Source,
|
||||
TargetType: report.Target.Type, TargetID: report.Target.ID,
|
||||
Reason: report.Reason, Option: report.Option,
|
||||
CommentHash: report.CommentHash, TaxonomyVersion: report.TaxonomyVersion,
|
||||
Items: items,
|
||||
})
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, ErrModerationReportInvalid
|
||||
}
|
||||
return sha256.Sum256(raw), nil
|
||||
}
|
||||
|
||||
func cloneModerationItems(items []ModerationReportItem) []ModerationReportItem {
|
||||
out := make([]ModerationReportItem, len(items))
|
||||
copy(out, items)
|
||||
for i := range out {
|
||||
out[i].Evidence = append(json.RawMessage(nil), items[i].Evidence...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CloneModerationReport(report ModerationReport) ModerationReport {
|
||||
report.Items = cloneModerationItems(report.Items)
|
||||
report.MediaHolds = append([]ModerationMediaHold(nil), report.MediaHolds...)
|
||||
return report
|
||||
}
|
||||
523
internal/domain/moderation_case.go
Normal file
523
internal/domain/moderation_case.go
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxModerationActorBytes = 128
|
||||
MaxModerationDecisionCommandBytes = 120
|
||||
MaxModerationDecisionTextRunes = 2000
|
||||
MaxModerationActionPayload = 64 << 10
|
||||
MaxModerationCasePage = 100
|
||||
MaxModerationCaseDetailEntries = 100
|
||||
MaxModerationActionsPerCase = 100
|
||||
MaxModerationAppealTextRunes = 4000
|
||||
MaxModerationActionAttempts = 20
|
||||
MaxModerationAppealLinksPerCase = 20
|
||||
MaxModerationAppealLinkLifetime = 90 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type ModerationSeverity int16
|
||||
|
||||
const (
|
||||
ModerationSeverityLow ModerationSeverity = iota + 1
|
||||
ModerationSeverityMedium
|
||||
ModerationSeverityHigh
|
||||
ModerationSeverityCritical
|
||||
)
|
||||
|
||||
func (s ModerationSeverity) Valid() bool {
|
||||
return s >= ModerationSeverityLow && s <= ModerationSeverityCritical
|
||||
}
|
||||
|
||||
func ModerationSeverityForReason(reason ModerationReason) ModerationSeverity {
|
||||
switch reason {
|
||||
case ModerationReasonChildAbuse:
|
||||
return ModerationSeverityCritical
|
||||
case ModerationReasonViolence, ModerationReasonPornography,
|
||||
ModerationReasonIllegalDrugs, ModerationReasonPersonalDetails:
|
||||
return ModerationSeverityHigh
|
||||
case ModerationReasonFake, ModerationReasonCopyright:
|
||||
return ModerationSeverityMedium
|
||||
default:
|
||||
return ModerationSeverityLow
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationCaseStatus string
|
||||
|
||||
const (
|
||||
ModerationCaseOpen ModerationCaseStatus = "open"
|
||||
ModerationCaseInReview ModerationCaseStatus = "in_review"
|
||||
ModerationCaseActionPending ModerationCaseStatus = "action_pending"
|
||||
ModerationCaseActionFailed ModerationCaseStatus = "action_failed"
|
||||
ModerationCaseResolved ModerationCaseStatus = "resolved"
|
||||
ModerationCaseDismissed ModerationCaseStatus = "dismissed"
|
||||
ModerationCaseAppealReview ModerationCaseStatus = "appeal_review"
|
||||
)
|
||||
|
||||
func (s ModerationCaseStatus) Valid() bool {
|
||||
switch s {
|
||||
case ModerationCaseOpen, ModerationCaseInReview,
|
||||
ModerationCaseActionPending, ModerationCaseResolved,
|
||||
ModerationCaseActionFailed, ModerationCaseDismissed,
|
||||
ModerationCaseAppealReview:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s ModerationCaseStatus) Active() bool {
|
||||
switch s {
|
||||
case ModerationCaseOpen, ModerationCaseInReview,
|
||||
ModerationCaseActionPending, ModerationCaseActionFailed,
|
||||
ModerationCaseAppealReview:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationDecisionKind string
|
||||
|
||||
const (
|
||||
ModerationDecisionNoViolation ModerationDecisionKind = "no_violation"
|
||||
ModerationDecisionViolation ModerationDecisionKind = "violation"
|
||||
ModerationDecisionAppealGrant ModerationDecisionKind = "appeal_granted"
|
||||
ModerationDecisionAppealDeny ModerationDecisionKind = "appeal_denied"
|
||||
)
|
||||
|
||||
func (k ModerationDecisionKind) Valid() bool {
|
||||
switch k {
|
||||
case ModerationDecisionNoViolation, ModerationDecisionViolation,
|
||||
ModerationDecisionAppealGrant, ModerationDecisionAppealDeny:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationActionKind string
|
||||
|
||||
const (
|
||||
ModerationActionMarkScam ModerationActionKind = "mark_scam"
|
||||
ModerationActionMarkFake ModerationActionKind = "mark_fake"
|
||||
ModerationActionClearPeerFlags ModerationActionKind = "clear_peer_flags"
|
||||
ModerationActionFreezeAccount ModerationActionKind = "freeze_account"
|
||||
ModerationActionUnfreezeAccount ModerationActionKind = "unfreeze_account"
|
||||
ModerationActionDeletePrivateMessage ModerationActionKind = "delete_private_message"
|
||||
ModerationActionDeleteChannelMessage ModerationActionKind = "delete_channel_message"
|
||||
ModerationActionDeleteAccount ModerationActionKind = "delete_account"
|
||||
)
|
||||
|
||||
func (k ModerationActionKind) Valid() bool {
|
||||
switch k {
|
||||
case ModerationActionMarkScam, ModerationActionMarkFake,
|
||||
ModerationActionClearPeerFlags, ModerationActionFreezeAccount,
|
||||
ModerationActionUnfreezeAccount,
|
||||
ModerationActionDeletePrivateMessage,
|
||||
ModerationActionDeleteChannelMessage,
|
||||
ModerationActionDeleteAccount:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationActionStatus string
|
||||
|
||||
const (
|
||||
ModerationActionPending ModerationActionStatus = "pending"
|
||||
ModerationActionProcessing ModerationActionStatus = "processing"
|
||||
ModerationActionSucceeded ModerationActionStatus = "succeeded"
|
||||
ModerationActionSuperseded ModerationActionStatus = "superseded"
|
||||
ModerationActionRetry ModerationActionStatus = "retry"
|
||||
ModerationActionFailed ModerationActionStatus = "failed"
|
||||
)
|
||||
|
||||
func (s ModerationActionStatus) Valid() bool {
|
||||
switch s {
|
||||
case ModerationActionPending, ModerationActionProcessing,
|
||||
ModerationActionSucceeded, ModerationActionSuperseded,
|
||||
ModerationActionRetry,
|
||||
ModerationActionFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ModerationSanctionFamily groups reversible actions that mutate the same
|
||||
// target-scoped state. Only the latest desired action in a family may execute;
|
||||
// older queued work is retained as superseded audit history.
|
||||
type ModerationSanctionFamily string
|
||||
|
||||
const (
|
||||
ModerationSanctionPeerFlags ModerationSanctionFamily = "peer_flags"
|
||||
ModerationSanctionAccountFreeze ModerationSanctionFamily = "account_freeze"
|
||||
)
|
||||
|
||||
func (k ModerationActionKind) SanctionFamily() (ModerationSanctionFamily, bool) {
|
||||
switch k {
|
||||
case ModerationActionMarkScam, ModerationActionMarkFake,
|
||||
ModerationActionClearPeerFlags:
|
||||
return ModerationSanctionPeerFlags, true
|
||||
case ModerationActionFreezeAccount, ModerationActionUnfreezeAccount:
|
||||
return ModerationSanctionAccountFreeze, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationAppealStatus string
|
||||
|
||||
const (
|
||||
ModerationAppealPending ModerationAppealStatus = "pending"
|
||||
ModerationAppealGranted ModerationAppealStatus = "granted"
|
||||
ModerationAppealRejected ModerationAppealStatus = "rejected"
|
||||
)
|
||||
|
||||
func (s ModerationAppealStatus) Valid() bool {
|
||||
switch s {
|
||||
case ModerationAppealPending, ModerationAppealGranted, ModerationAppealRejected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModerationCase struct {
|
||||
ID int64
|
||||
Target Peer
|
||||
Status ModerationCaseStatus
|
||||
Severity ModerationSeverity
|
||||
AssignedTo string
|
||||
Version int64
|
||||
ReportCount int
|
||||
DistinctReporterCount int
|
||||
FirstReportAt time.Time
|
||||
LastReportAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (c ModerationCase) Validate() error {
|
||||
if c.ID <= 0 || !moderationPeerValid(c.Target) || !c.Status.Valid() ||
|
||||
!c.Severity.Valid() || c.Version <= 0 || c.ReportCount <= 0 ||
|
||||
c.DistinctReporterCount <= 0 ||
|
||||
c.DistinctReporterCount > c.ReportCount ||
|
||||
len(c.AssignedTo) > MaxModerationActorBytes ||
|
||||
!utf8.ValidString(c.AssignedTo) || c.FirstReportAt.IsZero() ||
|
||||
c.LastReportAt.Before(c.FirstReportAt) || c.CreatedAt.IsZero() ||
|
||||
c.UpdatedAt.Before(c.CreatedAt) {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if (c.Status == ModerationCaseInReview ||
|
||||
c.Status == ModerationCaseActionPending ||
|
||||
c.Status == ModerationCaseActionFailed) && c.AssignedTo == "" {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ModerationCaseFilter struct {
|
||||
Statuses []ModerationCaseStatus
|
||||
AssignedTo string
|
||||
Target Peer
|
||||
BeforeUpdate time.Time
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (f ModerationCaseFilter) Validate() error {
|
||||
if f.Limit <= 0 || f.Limit > MaxModerationCasePage ||
|
||||
len(f.AssignedTo) > MaxModerationActorBytes ||
|
||||
!utf8.ValidString(f.AssignedTo) || f.BeforeID < 0 {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if f.Target.ID != 0 && !moderationPeerValid(f.Target) {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if f.Target.ID == 0 && f.Target.Type != "" {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
for _, status := range f.Statuses {
|
||||
if !status.Valid() {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ModerationCaseDetail struct {
|
||||
Case ModerationCase
|
||||
ReportIDs []int64
|
||||
Decisions []ModerationDecision
|
||||
Actions []ModerationAction
|
||||
Appeals []ModerationAppeal
|
||||
}
|
||||
|
||||
type ModerationDecision struct {
|
||||
ID int64
|
||||
CaseID int64
|
||||
AppealID int64
|
||||
Kind ModerationDecisionKind
|
||||
Actor string
|
||||
Reason string
|
||||
CommandID string
|
||||
Fingerprint [sha256.Size]byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ModerationActionDraft struct {
|
||||
Kind ModerationActionKind
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
type ModerationDecisionRequest struct {
|
||||
CaseID int64
|
||||
AppealID int64
|
||||
ExpectedVersion int64
|
||||
Actor string
|
||||
Reason string
|
||||
CommandID string
|
||||
Kind ModerationDecisionKind
|
||||
Actions []ModerationActionDraft
|
||||
Fingerprint [sha256.Size]byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewModerationDecisionRequest(request ModerationDecisionRequest) (ModerationDecisionRequest, error) {
|
||||
out := request
|
||||
out.Actions = append([]ModerationActionDraft(nil), request.Actions...)
|
||||
for i := range out.Actions {
|
||||
canonical, err := CanonicalModerationActionPayload(out.Actions[i].Payload)
|
||||
if err != nil {
|
||||
return ModerationDecisionRequest{}, err
|
||||
}
|
||||
out.Actions[i].Payload = canonical
|
||||
}
|
||||
fingerprint, err := moderationDecisionFingerprint(out)
|
||||
if err != nil {
|
||||
return ModerationDecisionRequest{}, err
|
||||
}
|
||||
out.Fingerprint = fingerprint
|
||||
if err := out.Validate(); err != nil {
|
||||
return ModerationDecisionRequest{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r ModerationDecisionRequest) Validate() error {
|
||||
if r.CaseID <= 0 || r.ExpectedVersion <= 0 || !r.Kind.Valid() ||
|
||||
r.Actor == "" || len(r.Actor) > MaxModerationActorBytes ||
|
||||
!utf8.ValidString(r.Actor) || r.CommandID == "" ||
|
||||
len(r.CommandID) > MaxModerationDecisionCommandBytes ||
|
||||
!utf8.ValidString(r.CommandID) ||
|
||||
r.Reason == "" || !utf8.ValidString(r.Reason) ||
|
||||
utf8.RuneCountInString(r.Reason) > MaxModerationDecisionTextRunes ||
|
||||
len(r.Actions) > MaxModerationActionsPerCase ||
|
||||
r.Fingerprint == ([sha256.Size]byte{}) || r.CreatedAt.IsZero() {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if r.Kind == ModerationDecisionNoViolation && len(r.Actions) != 0 {
|
||||
return ErrModerationActionInvalid
|
||||
}
|
||||
if r.Kind == ModerationDecisionViolation && len(r.Actions) == 0 {
|
||||
return ErrModerationActionInvalid
|
||||
}
|
||||
if (r.Kind == ModerationDecisionAppealGrant ||
|
||||
r.Kind == ModerationDecisionAppealDeny) != (r.AppealID > 0) {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if r.Kind == ModerationDecisionAppealDeny && len(r.Actions) != 0 {
|
||||
return ErrModerationActionInvalid
|
||||
}
|
||||
if (r.Kind == ModerationDecisionNoViolation ||
|
||||
r.Kind == ModerationDecisionViolation) && r.AppealID != 0 {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
for i := range r.Actions {
|
||||
canonical, err := CanonicalModerationActionPayload(r.Actions[i].Payload)
|
||||
if !r.Actions[i].Kind.Valid() || err != nil ||
|
||||
!bytes.Equal(canonical, r.Actions[i].Payload) {
|
||||
return ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
fingerprint, err := moderationDecisionFingerprint(r)
|
||||
if err != nil || fingerprint != r.Fingerprint {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ModerationAction struct {
|
||||
ID int64
|
||||
CaseID int64
|
||||
DecisionID int64
|
||||
Kind ModerationActionKind
|
||||
Payload json.RawMessage
|
||||
Status ModerationActionStatus
|
||||
Attempts int
|
||||
AvailableAt time.Time
|
||||
LeaseUntil time.Time
|
||||
LastError string
|
||||
CommandID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (a ModerationAction) Validate() error {
|
||||
canonical, err := CanonicalModerationActionPayload(a.Payload)
|
||||
if a.ID <= 0 || a.CaseID <= 0 || a.DecisionID <= 0 ||
|
||||
!a.Kind.Valid() || !a.Status.Valid() || a.Attempts < 0 ||
|
||||
a.Attempts > MaxModerationActionAttempts || a.AvailableAt.IsZero() ||
|
||||
a.CommandID == "" || len(a.CommandID) > 160 ||
|
||||
!utf8.ValidString(a.CommandID) || a.CreatedAt.IsZero() ||
|
||||
a.UpdatedAt.Before(a.CreatedAt) || err != nil ||
|
||||
!bytes.Equal(canonical, a.Payload) {
|
||||
return ErrModerationActionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ModerationAppeal struct {
|
||||
ID int64
|
||||
CaseID int64
|
||||
AppellantUserID int64
|
||||
Text string
|
||||
TextHash [sha256.Size]byte
|
||||
Fingerprint [sha256.Size]byte
|
||||
Status ModerationAppealStatus
|
||||
PreviousCaseStatus ModerationCaseStatus
|
||||
Reviewer string
|
||||
ReviewReason string
|
||||
CreatedAt time.Time
|
||||
ReviewedAt time.Time
|
||||
}
|
||||
|
||||
// ModerationAppealLink is a hash-only bearer capability issued for the user
|
||||
// targeted by a moderation case. The raw token is never persisted.
|
||||
type ModerationAppealLink struct {
|
||||
ID int64
|
||||
CaseID int64
|
||||
AppellantUserID int64
|
||||
TokenHash [sha256.Size]byte
|
||||
ExpiresAt time.Time
|
||||
AppealID int64
|
||||
CreatedAt time.Time
|
||||
ConsumedAt time.Time
|
||||
}
|
||||
|
||||
func (l ModerationAppealLink) Validate() error {
|
||||
if l.ID < 0 || l.CaseID <= 0 || l.AppellantUserID <= 0 ||
|
||||
l.TokenHash == ([sha256.Size]byte{}) || l.CreatedAt.IsZero() ||
|
||||
!l.ExpiresAt.After(l.CreatedAt) ||
|
||||
l.ExpiresAt.Sub(l.CreatedAt) > MaxModerationAppealLinkLifetime ||
|
||||
l.AppealID < 0 {
|
||||
return ErrModerationAppealLinkInvalid
|
||||
}
|
||||
if l.AppealID == 0 {
|
||||
if !l.ConsumedAt.IsZero() {
|
||||
return ErrModerationAppealLinkInvalid
|
||||
}
|
||||
} else if l.ConsumedAt.IsZero() {
|
||||
return ErrModerationAppealLinkInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewModerationAppeal(caseID, appellantUserID int64, previousStatus ModerationCaseStatus, text string, createdAt time.Time) (ModerationAppeal, error) {
|
||||
appeal := ModerationAppeal{
|
||||
CaseID: caseID, AppellantUserID: appellantUserID, Text: text,
|
||||
TextHash: sha256.Sum256([]byte(text)), Status: ModerationAppealPending,
|
||||
PreviousCaseStatus: previousStatus, CreatedAt: createdAt,
|
||||
}
|
||||
raw, err := json.Marshal(struct {
|
||||
Version int
|
||||
CaseID int64
|
||||
AppellantUserID int64
|
||||
PreviousStatus ModerationCaseStatus
|
||||
TextHash [sha256.Size]byte
|
||||
}{1, caseID, appellantUserID, previousStatus, appeal.TextHash})
|
||||
if err != nil {
|
||||
return ModerationAppeal{}, ErrModerationCaseInvalid
|
||||
}
|
||||
appeal.Fingerprint = sha256.Sum256(raw)
|
||||
if err := appeal.Validate(); err != nil {
|
||||
return ModerationAppeal{}, err
|
||||
}
|
||||
return appeal, nil
|
||||
}
|
||||
|
||||
func (a ModerationAppeal) Validate() error {
|
||||
if a.ID < 0 || a.CaseID <= 0 || a.AppellantUserID <= 0 ||
|
||||
a.Text == "" || !utf8.ValidString(a.Text) ||
|
||||
utf8.RuneCountInString(a.Text) > MaxModerationAppealTextRunes ||
|
||||
a.TextHash != sha256.Sum256([]byte(a.Text)) ||
|
||||
a.Fingerprint == ([sha256.Size]byte{}) || !a.Status.Valid() ||
|
||||
(a.PreviousCaseStatus != ModerationCaseResolved &&
|
||||
a.PreviousCaseStatus != ModerationCaseDismissed) ||
|
||||
len(a.Reviewer) > MaxModerationActorBytes ||
|
||||
!utf8.ValidString(a.Reviewer) || !utf8.ValidString(a.ReviewReason) ||
|
||||
utf8.RuneCountInString(a.ReviewReason) > MaxModerationDecisionTextRunes ||
|
||||
a.CreatedAt.IsZero() {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
if a.Status == ModerationAppealPending {
|
||||
if a.Reviewer != "" || a.ReviewReason != "" || !a.ReviewedAt.IsZero() {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
} else if a.Reviewer == "" || a.ReviewReason == "" || a.ReviewedAt.IsZero() {
|
||||
return ErrModerationCaseInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CanonicalModerationActionPayload(raw json.RawMessage) (json.RawMessage, error) {
|
||||
var value any
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage(`{}`)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, ErrModerationActionInvalid
|
||||
}
|
||||
if _, ok := value.(map[string]any); !ok {
|
||||
return nil, ErrModerationActionInvalid
|
||||
}
|
||||
canonical, err := json.Marshal(value)
|
||||
if err != nil || len(canonical) > MaxModerationActionPayload {
|
||||
return nil, ErrModerationActionInvalid
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func moderationDecisionFingerprint(request ModerationDecisionRequest) ([sha256.Size]byte, error) {
|
||||
raw, err := json.Marshal(struct {
|
||||
Version int
|
||||
CaseID int64
|
||||
AppealID int64
|
||||
ExpectedVersion int64
|
||||
Actor string
|
||||
Reason string
|
||||
CommandID string
|
||||
Kind ModerationDecisionKind
|
||||
Actions []ModerationActionDraft
|
||||
}{
|
||||
Version: 1, CaseID: request.CaseID,
|
||||
AppealID: request.AppealID,
|
||||
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
|
||||
Reason: request.Reason, CommandID: request.CommandID,
|
||||
Kind: request.Kind, Actions: request.Actions,
|
||||
})
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, ErrModerationCaseInvalid
|
||||
}
|
||||
return sha256.Sum256(raw), nil
|
||||
}
|
||||
156
internal/domain/moderation_registry.go
Normal file
156
internal/domain/moderation_registry.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MaxSponsoredImpressionLifetime = 30 * 24 * time.Hour
|
||||
|
||||
// SponsoredMessageImpression is the server-issued fact required before a
|
||||
// random_id may enter the human moderation pipeline.
|
||||
type SponsoredMessageImpression struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
RandomIDHash [sha256.Size]byte
|
||||
Target Peer
|
||||
AuthorUserID int64
|
||||
EvidenceSchemaVersion int
|
||||
Evidence json.RawMessage
|
||||
EvidenceHash [sha256.Size]byte
|
||||
ReportID int64
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func NewSponsoredMessageImpression(userID int64, randomID []byte, target Peer, authorUserID int64, evidence json.RawMessage, createdAt, expiresAt time.Time) (SponsoredMessageImpression, error) {
|
||||
canonical, err := CanonicalModerationEvidence(evidence)
|
||||
if err != nil {
|
||||
return SponsoredMessageImpression{}, ErrModerationReportInvalid
|
||||
}
|
||||
impression := SponsoredMessageImpression{
|
||||
UserID: userID, RandomIDHash: sha256.Sum256(randomID),
|
||||
Target: target, AuthorUserID: authorUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: canonical,
|
||||
EvidenceHash: sha256.Sum256(canonical),
|
||||
CreatedAt: createdAt.UTC(), ExpiresAt: expiresAt.UTC(),
|
||||
}
|
||||
if err := impression.Validate(); err != nil {
|
||||
return SponsoredMessageImpression{}, err
|
||||
}
|
||||
return impression, nil
|
||||
}
|
||||
|
||||
func (i SponsoredMessageImpression) Validate() error {
|
||||
canonical, err := CanonicalModerationEvidence(i.Evidence)
|
||||
if i.ID < 0 || i.UserID <= 0 ||
|
||||
i.RandomIDHash == ([sha256.Size]byte{}) ||
|
||||
!moderationPeerValid(i.Target) || i.AuthorUserID < 0 ||
|
||||
i.EvidenceSchemaVersion <= 0 ||
|
||||
i.EvidenceHash != sha256.Sum256(i.Evidence) ||
|
||||
err != nil || !bytes.Equal(canonical, i.Evidence) ||
|
||||
i.ReportID < 0 || i.CreatedAt.IsZero() ||
|
||||
!i.ExpiresAt.After(i.CreatedAt) ||
|
||||
i.ExpiresAt.Sub(i.CreatedAt) > MaxSponsoredImpressionLifetime {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateSponsoredModerationReport(impression SponsoredMessageImpression, report ModerationReport) error {
|
||||
if err := impression.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := report.Validate(); err != nil ||
|
||||
report.ReporterUserID != impression.UserID ||
|
||||
report.Source != ModerationSourceSponsored ||
|
||||
report.Target != impression.Target ||
|
||||
report.CreatedAt.Before(impression.CreatedAt) ||
|
||||
!report.CreatedAt.Before(impression.ExpiresAt) ||
|
||||
len(report.Items) != 1 || len(report.MediaHolds) != 0 {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
item := report.Items[0]
|
||||
if item.Kind != ModerationItemSponsored ||
|
||||
item.Peer != impression.Target ||
|
||||
item.ItemID != impression.ID || item.SecondaryID != 0 ||
|
||||
item.AuthorUserID != impression.AuthorUserID ||
|
||||
item.EvidenceSchemaVersion != impression.EvidenceSchemaVersion ||
|
||||
item.EvidenceHash != impression.EvidenceHash ||
|
||||
!bytes.Equal(item.Evidence, impression.Evidence) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChannelAntiSpamDecision is immutable evidence that native anti-spam
|
||||
// actually removed the referenced message. A false-positive report without
|
||||
// this fact must fail closed.
|
||||
type ChannelAntiSpamDecision struct {
|
||||
ID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
AuthorUserID int64
|
||||
EvidenceSchemaVersion int
|
||||
Evidence json.RawMessage
|
||||
EvidenceHash [sha256.Size]byte
|
||||
ReportID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewChannelAntiSpamDecision(channelID int64, messageID int, authorUserID int64, evidence json.RawMessage, createdAt time.Time) (ChannelAntiSpamDecision, error) {
|
||||
canonical, err := CanonicalModerationEvidence(evidence)
|
||||
if err != nil {
|
||||
return ChannelAntiSpamDecision{}, ErrModerationReportInvalid
|
||||
}
|
||||
decision := ChannelAntiSpamDecision{
|
||||
ChannelID: channelID, MessageID: messageID,
|
||||
AuthorUserID: authorUserID, EvidenceSchemaVersion: 1,
|
||||
Evidence: canonical, EvidenceHash: sha256.Sum256(canonical),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
}
|
||||
if err := decision.Validate(); err != nil {
|
||||
return ChannelAntiSpamDecision{}, err
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (d ChannelAntiSpamDecision) Validate() error {
|
||||
canonical, err := CanonicalModerationEvidence(d.Evidence)
|
||||
if d.ID < 0 || d.ChannelID <= 0 || d.MessageID <= 0 ||
|
||||
d.MessageID > MaxMessageBoxID || d.AuthorUserID <= 0 ||
|
||||
d.EvidenceSchemaVersion <= 0 ||
|
||||
d.EvidenceHash != sha256.Sum256(d.Evidence) ||
|
||||
err != nil || !bytes.Equal(canonical, d.Evidence) ||
|
||||
d.ReportID < 0 || d.CreatedAt.IsZero() {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateAntiSpamFalsePositiveReport(decision ChannelAntiSpamDecision, report ModerationReport) error {
|
||||
if err := decision.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
target := Peer{Type: PeerTypeChannel, ID: decision.ChannelID}
|
||||
if err := report.Validate(); err != nil ||
|
||||
report.Source != ModerationSourceAntiSpamFalsePositive ||
|
||||
report.Target != target ||
|
||||
report.CreatedAt.Before(decision.CreatedAt) ||
|
||||
len(report.Items) != 1 || len(report.MediaHolds) != 0 {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
item := report.Items[0]
|
||||
if item.Kind != ModerationItemAntiSpamDecision ||
|
||||
item.Peer != target || item.ItemID != decision.ID ||
|
||||
item.SecondaryID != int64(decision.MessageID) ||
|
||||
item.AuthorUserID != decision.AuthorUserID ||
|
||||
item.EvidenceSchemaVersion != decision.EvidenceSchemaVersion ||
|
||||
item.EvidenceHash != decision.EvidenceHash ||
|
||||
!bytes.Equal(item.Evidence, decision.Evidence) {
|
||||
return ErrModerationReportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
87
internal/domain/moderation_test.go
Normal file
87
internal/domain/moderation_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewModerationReportCanonicalizesEvidenceItemsAndHolds(t *testing.T) {
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
draft := ModerationReportDraft{
|
||||
ReporterUserID: 101,
|
||||
Source: ModerationSourceMessages,
|
||||
Target: Peer{Type: PeerTypeChannel, ID: 202},
|
||||
Reason: ModerationReasonSpam,
|
||||
Option: "v1/spam",
|
||||
Comment: "review",
|
||||
CreatedAt: now,
|
||||
Items: []ModerationReportItem{
|
||||
{
|
||||
Kind: ModerationItemStory, Peer: Peer{Type: PeerTypeChannel, ID: 202},
|
||||
ItemID: 20, AuthorUserID: 303, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{ "z": 1, "a": {"two": 2, "one": 1} }`),
|
||||
},
|
||||
{
|
||||
Kind: ModerationItemMessage, Peer: Peer{Type: PeerTypeChannel, ID: 202},
|
||||
ItemID: 10, AuthorUserID: 303, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"message":"spam"}`),
|
||||
},
|
||||
},
|
||||
MediaHolds: []ModerationMediaHold{{
|
||||
ItemIndex: 0, Kind: ModerationMediaPhoto, StorageKey: "photo/20",
|
||||
}},
|
||||
}
|
||||
report, err := NewModerationReport(draft)
|
||||
if err != nil {
|
||||
t.Fatalf("NewModerationReport: %v", err)
|
||||
}
|
||||
if report.Items[0].Kind != ModerationItemMessage || report.Items[1].Kind != ModerationItemStory {
|
||||
t.Fatalf("items not canonicalized: %+v", report.Items)
|
||||
}
|
||||
if report.MediaHolds[0].ItemIndex != 1 {
|
||||
t.Fatalf("media hold item index = %d, want 1 after canonical sort", report.MediaHolds[0].ItemIndex)
|
||||
}
|
||||
if got, want := report.Items[1].Evidence, []byte(`{"a":{"one":1,"two":2},"z":1}`); !bytes.Equal(got, want) {
|
||||
t.Fatalf("canonical evidence = %s, want %s", got, want)
|
||||
}
|
||||
if err := report.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
|
||||
retry := draft
|
||||
retry.CreatedAt = now.Add(time.Hour)
|
||||
retryReport, err := NewModerationReport(retry)
|
||||
if err != nil {
|
||||
t.Fatalf("retry NewModerationReport: %v", err)
|
||||
}
|
||||
if retryReport.Fingerprint != report.Fingerprint {
|
||||
t.Fatalf("retry fingerprint changed with CreatedAt")
|
||||
}
|
||||
retry.Items = append([]ModerationReportItem(nil), retry.Items...)
|
||||
retry.Items[0].Evidence = []byte(`{"z":2}`)
|
||||
changed, err := NewModerationReport(retry)
|
||||
if err != nil {
|
||||
t.Fatalf("changed NewModerationReport: %v", err)
|
||||
}
|
||||
if changed.Fingerprint == report.Fingerprint {
|
||||
t.Fatalf("evidence change did not change fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModerationReportRejectsDuplicateItemIdentity(t *testing.T) {
|
||||
item := ModerationReportItem{
|
||||
Kind: ModerationItemMessage, Peer: Peer{Type: PeerTypeUser, ID: 2},
|
||||
ItemID: 7, AuthorUserID: 2, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"message":"bad"}`),
|
||||
}
|
||||
_, err := NewModerationReport(ModerationReportDraft{
|
||||
ReporterUserID: 1, Source: ModerationSourceMessages,
|
||||
Target: Peer{Type: PeerTypeUser, ID: 2},
|
||||
Reason: ModerationReasonSpam, Option: "v1/spam",
|
||||
Items: []ModerationReportItem{item, item}, CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != ErrModerationReportInvalid {
|
||||
t.Fatalf("error = %v, want ErrModerationReportInvalid", err)
|
||||
}
|
||||
}
|
||||
10
internal/domain/premium.go
Normal file
10
internal/domain/premium.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package domain
|
||||
|
||||
// PremiumPromoCatalog is the immutable, domain-only media catalog returned by
|
||||
// help.getPremiumPromo. VideoSections[i] describes Videos[i]; callers must
|
||||
// preserve the one-to-one ordering because official clients use positional
|
||||
// lookup.
|
||||
type PremiumPromoCatalog struct {
|
||||
VideoSections []string
|
||||
Videos []Document
|
||||
}
|
||||
|
|
@ -75,6 +75,10 @@ func DefaultPrivacyRules(key PrivacyKey) []PrivacyRule {
|
|||
switch key {
|
||||
case PrivacyKeyPhoneNumber:
|
||||
return []PrivacyRule{{Kind: PrivacyRuleDisallowAll}}
|
||||
case PrivacyKeyNoPaidMessages:
|
||||
// This key is an allow-list of peers exempt from paid private
|
||||
// messages, not the base visibility of a profile field.
|
||||
return []PrivacyRule{{Kind: PrivacyRuleDisallowAll}}
|
||||
case PrivacyKeyBirthday:
|
||||
return []PrivacyRule{{Kind: PrivacyRuleAllowContacts}}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -353,6 +353,7 @@ type AdminStarGiftGrant struct {
|
|||
CommandKey string
|
||||
Date int
|
||||
RecipientBlocked bool
|
||||
RecipientUnsaved bool
|
||||
ModelAttributeID int64
|
||||
PatternAttributeID int64
|
||||
BackdropAttributeID int64
|
||||
|
|
@ -382,6 +383,7 @@ type StarGiftPurchaseRequest struct {
|
|||
CommandKey string
|
||||
Date int
|
||||
RecipientBlocked bool
|
||||
RecipientUnsaved bool
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
|
@ -555,15 +557,16 @@ type StarGiftValueInfo struct {
|
|||
}
|
||||
|
||||
type StarGiftTransferRequest struct {
|
||||
ActorUserID int64
|
||||
Ref SavedStarGiftRef
|
||||
To Peer
|
||||
ChargeStars int64
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
ActorUserID int64
|
||||
Ref SavedStarGiftRef
|
||||
To Peer
|
||||
ChargeStars int64
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
RecipientUnsaved bool
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
type StarGiftTransferResult struct {
|
||||
|
|
@ -582,15 +585,16 @@ type StarGiftListingRequest struct {
|
|||
}
|
||||
|
||||
type StarGiftResalePurchaseRequest struct {
|
||||
BuyerUserID int64
|
||||
Slug string
|
||||
To Peer
|
||||
Amount StarGiftAmount
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
BuyerUserID int64
|
||||
Slug string
|
||||
To Peer
|
||||
Amount StarGiftAmount
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
RecipientUnsaved bool
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
type StarGiftOfferRequest struct {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,86 @@ type StarsBalance struct {
|
|||
Granted bool // 起始授予是否已应用(惰性首读授予的幂等守卫)
|
||||
}
|
||||
|
||||
// StarsPurchaseKind identifies the balance owner affected by a fiat Stars
|
||||
// checkout. It is persisted with the form so a client cannot reinterpret a
|
||||
// self top-up as a friend gift (or vice versa) when submitting the form.
|
||||
type StarsPurchaseKind string
|
||||
|
||||
const (
|
||||
StarsPurchaseTopup StarsPurchaseKind = "topup"
|
||||
StarsPurchaseGift StarsPurchaseKind = "gift"
|
||||
StarsPurchaseGiveaway StarsPurchaseKind = "giveaway"
|
||||
)
|
||||
|
||||
func (k StarsPurchaseKind) Valid() bool {
|
||||
return k == StarsPurchaseTopup || k == StarsPurchaseGift || k == StarsPurchaseGiveaway
|
||||
}
|
||||
|
||||
// StarsGiveawayPurchase is the complete immutable purpose behind one direct
|
||||
// fiat Stars giveaway checkout. The launch purchase persists this shape; the
|
||||
// eventual winner draw is a separate lifecycle transition.
|
||||
type StarsGiveawayPurchase struct {
|
||||
BoostPeer Peer `json:"boost_peer"`
|
||||
AdditionalPeers []Peer `json:"additional_peers,omitempty"`
|
||||
CountriesISO2 []string `json:"countries_iso2,omitempty"`
|
||||
PrizeDescription string `json:"prize_description,omitempty"`
|
||||
RandomID int64 `json:"random_id"`
|
||||
UntilDate int `json:"until_date"`
|
||||
Users int `json:"users"`
|
||||
PerUserStars int64 `json:"per_user_stars"`
|
||||
YearlyBoosts int `json:"yearly_boosts"`
|
||||
OnlyNewSubscribers bool `json:"only_new_subscribers,omitempty"`
|
||||
WinnersAreVisible bool `json:"winners_are_visible,omitempty"`
|
||||
}
|
||||
|
||||
// StarsPurchaseForm binds one short-lived fiat Stars checkout to its
|
||||
// authenticated buyer, purpose and exact server-advertised package. Recipient
|
||||
// is zero for a self top-up and mandatory for a friend gift.
|
||||
type StarsPurchaseForm struct {
|
||||
FormID int64
|
||||
Kind StarsPurchaseKind
|
||||
BuyerUserID int64
|
||||
RecipientUserID int64
|
||||
SpendPurposePeer Peer
|
||||
Giveaway *StarsGiveawayPurchase
|
||||
Stars int64
|
||||
Currency string
|
||||
Amount int64
|
||||
IssuedAt int
|
||||
ExpiresAt int
|
||||
}
|
||||
|
||||
// StarsPurchaseRequest is the immutable settlement command carried by
|
||||
// inputInvoiceStars. Android and TDesktop sendPaymentForm both resolve to this
|
||||
// command after the ordinary fiat checkout has produced provider credentials.
|
||||
type StarsPurchaseRequest struct {
|
||||
StarsPurchaseForm
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// StarsPurchaseResult is the atomically committed credit and, for a friend
|
||||
// gift, bilateral service-message receipt. Duplicate means an exact form replay.
|
||||
type StarsPurchaseResult struct {
|
||||
Balance StarsBalance
|
||||
Send SendPrivateTextResult
|
||||
ChannelSend SendChannelMessageResult
|
||||
TransactionID string
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// StarsGiveawayInfo is the viewer-specific state of one durable launch card.
|
||||
// Winner selection/results are intentionally outside the purchase aggregate.
|
||||
type StarsGiveawayInfo struct {
|
||||
StartDate int
|
||||
Participating bool
|
||||
PreparingResults bool
|
||||
JoinedTooEarlyDate int
|
||||
AdminDisallowedChatID int64
|
||||
DisallowedCountry string
|
||||
}
|
||||
|
||||
// StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。
|
||||
type StarsTransactionReason string
|
||||
|
||||
|
|
@ -53,6 +133,57 @@ type StarsTransaction struct {
|
|||
// IsCredit 报告该流水是否为入账(贷记),投影到 tg.StarsTransaction.Refund。
|
||||
func (t StarsTransaction) IsCredit() bool { return t.Amount > 0 }
|
||||
|
||||
// StarsTransactionDirection scopes one payments.getStarsTransactions view.
|
||||
// The zero value intentionally means the combined inbound/outbound history.
|
||||
type StarsTransactionDirection uint8
|
||||
|
||||
const (
|
||||
StarsTransactionDirectionAll StarsTransactionDirection = iota
|
||||
StarsTransactionDirectionIncoming
|
||||
StarsTransactionDirectionOutgoing
|
||||
)
|
||||
|
||||
func (d StarsTransactionDirection) Valid() bool {
|
||||
return d <= StarsTransactionDirectionOutgoing
|
||||
}
|
||||
|
||||
func (d StarsTransactionDirection) IncludesAmount(amount int64) bool {
|
||||
switch d {
|
||||
case StarsTransactionDirectionAll:
|
||||
return true
|
||||
case StarsTransactionDirectionIncoming:
|
||||
return amount > 0
|
||||
case StarsTransactionDirectionOutgoing:
|
||||
return amount < 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// StarsTransactionQuery keeps direction, ordering and the opaque keyset cursor
|
||||
// together so filtering is applied before LIMIT in every ledger backend.
|
||||
type StarsTransactionQuery struct {
|
||||
Offset string
|
||||
Limit int
|
||||
Direction StarsTransactionDirection
|
||||
Ascending bool
|
||||
}
|
||||
|
||||
// NormalizeStarsTransactionQuery preserves the existing bounded limit/offset
|
||||
// behavior while rejecting impossible internal direction values.
|
||||
func NormalizeStarsTransactionQuery(query StarsTransactionQuery) (StarsTransactionQuery, error) {
|
||||
if !query.Direction.Valid() {
|
||||
return StarsTransactionQuery{}, ErrStarsTransactionQueryInvalid
|
||||
}
|
||||
if len(query.Offset) > MaxStarsTransactionsOffsetBytes {
|
||||
query.Offset = ""
|
||||
}
|
||||
if query.Limit <= 0 || query.Limit > MaxStarsTransactionsLimit {
|
||||
query.Limit = MaxStarsTransactionsLimit
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// StarsTransactionPage 是一页账本流水 + 当前余额 + 分页游标 + 对手方用户富化集合。
|
||||
type StarsTransactionPage struct {
|
||||
Balance int64
|
||||
|
|
@ -99,6 +230,14 @@ var (
|
|||
ErrStarsInsufficient = errors.New("stars: insufficient balance")
|
||||
// ErrStarsInvalidAmount 表示金额非法(<=0)。
|
||||
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
|
||||
// ErrStarsTransactionQueryInvalid 表示内部构造了不可能的流水方向。
|
||||
ErrStarsTransactionQueryInvalid = errors.New("stars: invalid transaction query")
|
||||
// ErrStarsPurchaseFormInvalid covers a missing/cross-account/mutated form.
|
||||
ErrStarsPurchaseFormInvalid = errors.New("stars: purchase form invalid")
|
||||
// ErrStarsPurchaseFormExpired is returned before any settlement write.
|
||||
ErrStarsPurchaseFormExpired = errors.New("stars: purchase form expired")
|
||||
// ErrStarsGiftUnavailable covers a recipient that cannot receive the gift.
|
||||
ErrStarsGiftUnavailable = errors.New("stars: gift unavailable")
|
||||
)
|
||||
|
||||
// StarsPaymentRequiredError reports the minimum paid-message authorization the
|
||||
|
|
|
|||
|
|
@ -38,6 +38,28 @@ const (
|
|||
// 与 files.Service.SeedChatBotAvatar 种子写入的行保持一致。
|
||||
ChatBotUserPhotoID int64 = 12500000070001
|
||||
ChatBotUserPhotoAccessHash int64 = 8748578814399338333
|
||||
|
||||
// VerifyBotUserID is the built-in @verifybot: it collects official platform
|
||||
// verification applications and reports decisions back to the applicant. The
|
||||
// id is reserved and stable, so a restart never re-creates the account under a
|
||||
// different identity.
|
||||
VerifyBotUserID int64 = 1250000011
|
||||
// VerifyBotAccessHash is fixed and double-written with the seed row in
|
||||
// migration 0153; the two must never drift.
|
||||
VerifyBotAccessHash int64 = 7802113947355620887
|
||||
|
||||
// VerifierBotUserID is the built-in @verifierbot: the first THIRD-PARTY
|
||||
// verifier of a deployment (core.telegram.org/api/bots/verification). It
|
||||
// collects applications for its own icon+description mark and reports the
|
||||
// operator's decision back to the applicant. The id is reserved and stable, so
|
||||
// a restart never re-creates the account under a different identity.
|
||||
//
|
||||
// It is not a second route to the platform checkmark: that badge is granted by
|
||||
// the operator alone and collected by VerifyBotUserID above.
|
||||
VerifierBotUserID int64 = 1250000013
|
||||
// VerifierBotAccessHash is fixed and double-written with the seed row in
|
||||
// migration 0156; the two must never drift.
|
||||
VerifierBotAccessHash int64 = 6913402578811563729
|
||||
)
|
||||
|
||||
// officialSystemUserPhotoDCID/Stripped 由 files.Service.SeedOfficialSystemAvatar
|
||||
|
|
@ -173,6 +195,38 @@ func ChatBotUser() User {
|
|||
return u
|
||||
}
|
||||
|
||||
// VerifyBotUser returns the built-in @verifybot account. It is verified itself,
|
||||
// so the applicant sees the same badge on the account that grants it.
|
||||
func VerifyBotUser() User {
|
||||
return User{
|
||||
ID: VerifyBotUserID,
|
||||
AccessHash: VerifyBotAccessHash,
|
||||
FirstName: "Verify Bot",
|
||||
Username: "verifybot",
|
||||
Verified: true,
|
||||
Bot: true,
|
||||
BotInfoVersion: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifierBotUser returns the built-in @verifierbot account.
|
||||
//
|
||||
// Verified is false on purpose: the official checkmark is the platform's own
|
||||
// mechanism, and a third-party verifier wearing it would blur exactly the
|
||||
// distinction this bot has to explain to every applicant. What makes the account a
|
||||
// verifier is the operator-granted BotVerifierSettings row, not this seed.
|
||||
func VerifierBotUser() User {
|
||||
return User{
|
||||
ID: VerifierBotUserID,
|
||||
AccessHash: VerifierBotAccessHash,
|
||||
FirstName: "Verifier Bot",
|
||||
Username: "verifierbot",
|
||||
Verified: false,
|
||||
Bot: true,
|
||||
BotInfoVersion: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// SystemUserByID 返回内置系统账号;非系统账号返回 ok=false。
|
||||
// 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。
|
||||
func SystemUserByID(id int64) (User, bool) {
|
||||
|
|
@ -185,6 +239,10 @@ func SystemUserByID(id int64) (User, bool) {
|
|||
return StickersBotUser(), true
|
||||
case ChatBotUserID:
|
||||
return ChatBotUser(), true
|
||||
case VerifyBotUserID:
|
||||
return VerifyBotUser(), true
|
||||
case VerifierBotUserID:
|
||||
return VerifierBotUser(), true
|
||||
}
|
||||
return User{}, false
|
||||
}
|
||||
|
|
@ -194,9 +252,25 @@ func IsSystemUserID(id int64) bool {
|
|||
return ok
|
||||
}
|
||||
|
||||
// SystemUserIDs returns every built-in account id, in a stable order.
|
||||
//
|
||||
// It is the one list to extend when a service account is added, so a caller that
|
||||
// has to enumerate them -- a SQL predicate excluding infrastructure, say -- cannot
|
||||
// silently miss one the way an inline literal would.
|
||||
func SystemUserIDs() []int64 {
|
||||
return []int64{
|
||||
OfficialSystemUserID,
|
||||
BotFatherUserID,
|
||||
StickersBotUserID,
|
||||
ChatBotUserID,
|
||||
VerifyBotUserID,
|
||||
VerifierBotUserID,
|
||||
}
|
||||
}
|
||||
|
||||
func SystemUserByPhone(phone string) (User, bool) {
|
||||
phone = NormalizePhone(phone)
|
||||
for _, id := range []int64{OfficialSystemUserID, BotFatherUserID, StickersBotUserID, ChatBotUserID} {
|
||||
for _, id := range SystemUserIDs() {
|
||||
u, ok := SystemUserByID(id)
|
||||
if !ok || u.Phone == "" {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -18,11 +18,10 @@ const (
|
|||
// UpdateEventWebPage 映射 updateWebPage:异步解析完成后把消息里的 pending 链接预览
|
||||
// 占位就地替换为已解析卡片。携带账号 pts(非 LacksWirePts),消息快照经 box JOIN 重建,
|
||||
// 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。
|
||||
UpdateEventWebPage UpdateEventType = "web_page"
|
||||
UpdateEventMessageReactions UpdateEventType = "message_reactions"
|
||||
UpdateEventWebPage UpdateEventType = "web_page"
|
||||
// UpdateEventMessagePoll 映射 updateMessagePoll(投票/关闭后 poll 状态变化;
|
||||
// Message 为该 owner 视角消息,media 在 difference 重放时按 viewer 重新 enrich)。
|
||||
// 与 reaction 同款:占账号 pts 但 TL 构造器无 pts,见 LacksWirePts。
|
||||
// 当前历史实现仍将 poll 作为待复核的 LacksWirePts 事件。
|
||||
UpdateEventMessagePoll UpdateEventType = "message_poll"
|
||||
UpdateEventContactsReset UpdateEventType = "contacts_reset"
|
||||
UpdateEventDialogPinned UpdateEventType = "dialog_pinned"
|
||||
|
|
@ -45,7 +44,6 @@ const (
|
|||
UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order"
|
||||
UpdateEventDialogFilters UpdateEventType = "dialog_filters"
|
||||
UpdateEventFolderPeers UpdateEventType = "folder_peers"
|
||||
UpdateEventChannelAvailable UpdateEventType = "channel_available_messages"
|
||||
UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages"
|
||||
UpdateEventStory UpdateEventType = "story"
|
||||
UpdateEventReadStories UpdateEventType = "read_stories"
|
||||
|
|
@ -126,8 +124,7 @@ type UpdateEvent struct {
|
|||
// 下一条真正带 pts 的更新会被判为空洞。
|
||||
func (e UpdateEvent) LacksWirePts() bool {
|
||||
switch e.Type {
|
||||
case UpdateEventMessageReactions,
|
||||
UpdateEventMessagePoll,
|
||||
case UpdateEventMessagePoll,
|
||||
UpdateEventDraftMessage,
|
||||
UpdateEventChannelState,
|
||||
UpdateEventContactsReset,
|
||||
|
|
@ -143,7 +140,6 @@ func (e UpdateEvent) LacksWirePts() bool {
|
|||
UpdateEventDialogFilter,
|
||||
UpdateEventDialogFilterOrder,
|
||||
UpdateEventDialogFilters,
|
||||
UpdateEventChannelAvailable,
|
||||
UpdateEventChannelViewForum,
|
||||
UpdateEventStory,
|
||||
UpdateEventReadStories,
|
||||
|
|
@ -180,7 +176,10 @@ type UpdateDifference struct {
|
|||
|
||||
// ChannelDifferenceNudge is a computed account-level hint that a channel diff is dirty.
|
||||
type ChannelDifferenceNudge struct {
|
||||
ChannelID int64
|
||||
Pts int
|
||||
Channel *ChannelView
|
||||
ChannelID int64
|
||||
Pts int
|
||||
ChannelUpdatesDirty bool
|
||||
AvailableMinID int
|
||||
HistoryClearDate int
|
||||
Channel *ChannelView
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,26 @@ type UserStatus struct {
|
|||
WasOnline int
|
||||
}
|
||||
|
||||
// ApproximateUserStatus returns Telegram's coarse privacy-preserving last-seen
|
||||
// buckets. Exact online/offline timestamps must never be reattached after this
|
||||
// projection.
|
||||
func ApproximateUserStatus(lastSeenAt, now int) UserStatus {
|
||||
if lastSeenAt <= 0 || now <= 0 || lastSeenAt >= now {
|
||||
return UserStatus{Kind: UserStatusRecently}
|
||||
}
|
||||
age := now - lastSeenAt
|
||||
switch {
|
||||
case age <= 3*24*60*60:
|
||||
return UserStatus{Kind: UserStatusRecently}
|
||||
case age <= 7*24*60*60:
|
||||
return UserStatus{Kind: UserStatusLastWeek}
|
||||
case age <= 30*24*60*60:
|
||||
return UserStatus{Kind: UserStatusLastMonth}
|
||||
default:
|
||||
return UserStatus{Kind: UserStatusEmpty}
|
||||
}
|
||||
}
|
||||
|
||||
// Birthday 是用户公开生日。Day/Month 为 0 表示未设置;Year 为 0 表示只填了月日不含年份。
|
||||
type Birthday struct {
|
||||
Day int
|
||||
|
|
|
|||
651
internal/domain/verification.go
Normal file
651
internal/domain/verification.go
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Official platform verification.
|
||||
//
|
||||
// This is the badge official clients render from user#b1b8cc83 verified:flags.17
|
||||
// and channel#d49f34c6 verified:flags.7 (layer 228). It is deliberately NOT the
|
||||
// third-party mechanism built on botVerification#f93cd45c /
|
||||
// bots.setCustomVerification#8b89dfbd / bot_verification_icon, where an outside
|
||||
// organisation attaches its own icon. The two must never be conflated: an
|
||||
// application reviewed here flips the platform flag on the peer record and
|
||||
// nothing else.
|
||||
//
|
||||
// Applications are filed through the built-in @verifybot and decided in the
|
||||
// admin panel. The application record is the durable audit subject: it is never
|
||||
// deleted, only moved through its status machine.
|
||||
|
||||
// Verification target kinds. A verification subject is always a public presence:
|
||||
// a bot, a public channel or a public supergroup. Ordinary user accounts are
|
||||
// excluded unless the operator opts in via configuration.
|
||||
type VerificationTargetType string
|
||||
|
||||
const (
|
||||
VerificationTargetBot VerificationTargetType = "bot"
|
||||
VerificationTargetChannel VerificationTargetType = "channel"
|
||||
VerificationTargetSupergroup VerificationTargetType = "supergroup"
|
||||
VerificationTargetUser VerificationTargetType = "user"
|
||||
)
|
||||
|
||||
// Valid reports whether the target type is modelled.
|
||||
func (t VerificationTargetType) Valid() bool {
|
||||
switch t {
|
||||
case VerificationTargetBot, VerificationTargetChannel, VerificationTargetSupergroup, VerificationTargetUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// PeerType maps the target kind onto the peer namespace that holds it: bots and
|
||||
// users live in the user namespace, channels and supergroups in the channel one.
|
||||
func (t VerificationTargetType) PeerType() PeerType {
|
||||
switch t {
|
||||
case VerificationTargetChannel, VerificationTargetSupergroup:
|
||||
return PeerTypeChannel
|
||||
default:
|
||||
return PeerTypeUser
|
||||
}
|
||||
}
|
||||
|
||||
// VerificationStatus is the application lifecycle.
|
||||
//
|
||||
// draft -- being filled in through the bot dialog, not yet visible to review
|
||||
// submitted -- awaiting a reviewer
|
||||
// in_review -- claimed by a reviewer
|
||||
// approved -- decided in favour; the target carries the platform flag
|
||||
// rejected -- decided against, with a mandatory reason
|
||||
// cancelled -- withdrawn by the applicant
|
||||
type VerificationStatus string
|
||||
|
||||
const (
|
||||
VerificationStatusDraft VerificationStatus = "draft"
|
||||
VerificationStatusSubmitted VerificationStatus = "submitted"
|
||||
VerificationStatusInReview VerificationStatus = "in_review"
|
||||
VerificationStatusApproved VerificationStatus = "approved"
|
||||
VerificationStatusRejected VerificationStatus = "rejected"
|
||||
VerificationStatusCancelled VerificationStatus = "cancelled"
|
||||
)
|
||||
|
||||
// Valid reports whether the status is modelled.
|
||||
func (s VerificationStatus) Valid() bool {
|
||||
switch s {
|
||||
case VerificationStatusDraft, VerificationStatusSubmitted, VerificationStatusInReview,
|
||||
VerificationStatusApproved, VerificationStatusRejected, VerificationStatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Active reports whether the application still occupies its target. Exactly one
|
||||
// active application per target is allowed, which the store enforces with a
|
||||
// partial unique index over these statuses.
|
||||
func (s VerificationStatus) Active() bool {
|
||||
switch s {
|
||||
case VerificationStatusDraft, VerificationStatusSubmitted, VerificationStatusInReview:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Decided reports whether a reviewer has closed the application.
|
||||
func (s VerificationStatus) Decided() bool {
|
||||
return s == VerificationStatusApproved || s == VerificationStatusRejected
|
||||
}
|
||||
|
||||
// CanTransitionVerificationStatus is the single source of truth for the status
|
||||
// machine. Both the bot dialog and the review API validate against it, so an
|
||||
// operator cannot drive an application into a state the applicant path forbids.
|
||||
func CanTransitionVerificationStatus(from, to VerificationStatus) bool {
|
||||
if !from.Valid() || !to.Valid() || from == to {
|
||||
return false
|
||||
}
|
||||
switch from {
|
||||
case VerificationStatusDraft:
|
||||
return to == VerificationStatusSubmitted || to == VerificationStatusCancelled
|
||||
case VerificationStatusSubmitted:
|
||||
return to == VerificationStatusInReview || to == VerificationStatusApproved ||
|
||||
to == VerificationStatusRejected || to == VerificationStatusCancelled
|
||||
case VerificationStatusInReview:
|
||||
return to == VerificationStatusApproved || to == VerificationStatusRejected ||
|
||||
to == VerificationStatusSubmitted || to == VerificationStatusCancelled
|
||||
default:
|
||||
// Decided and cancelled applications are terminal: history is kept, a new
|
||||
// attempt is a new application.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Field bounds. They are enforced in the domain so the bot dialog, the admin API
|
||||
// and both store backends reject the same inputs, and they mirror the CHECK
|
||||
// constraints on verification_applications.
|
||||
const (
|
||||
MaxVerificationCategoryLength = 64
|
||||
MaxVerificationDescriptionLength = 1024
|
||||
MinVerificationDescriptionLength = 40
|
||||
MaxVerificationCommentLength = 1024
|
||||
MaxVerificationURLLength = 512
|
||||
MaxVerificationSocialLinks = 10
|
||||
MaxVerificationPressLinks = 10
|
||||
// MinVerificationPressLinks is the official bar: independent coverage is what
|
||||
// distinguishes a verifiable public presence from a self-declared one.
|
||||
MinVerificationPressLinks = 2
|
||||
MaxVerificationReasonLength = 1024
|
||||
MaxVerificationNoteLength = 2048
|
||||
MaxVerificationTitleLength = 256
|
||||
MaxVerificationReviewerLength = 128
|
||||
MaxVerificationCorrelationLen = 128
|
||||
MaxVerificationEventPayloadSize = 8192
|
||||
)
|
||||
|
||||
// VerificationCategories is the closed set of project categories offered by the
|
||||
// bot. A closed set keeps the review queue groupable and keeps free text out of
|
||||
// a field the panel filters on.
|
||||
var VerificationCategories = []string{
|
||||
"media",
|
||||
"government",
|
||||
"company",
|
||||
"brand",
|
||||
"sport",
|
||||
"culture",
|
||||
"education",
|
||||
"nonprofit",
|
||||
"public_figure",
|
||||
"service",
|
||||
"other",
|
||||
}
|
||||
|
||||
// ValidVerificationCategory reports whether the category is offered.
|
||||
func ValidVerificationCategory(category string) bool {
|
||||
for _, item := range VerificationCategories {
|
||||
if item == category {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrVerificationApplicationNotFound reports a missing application.
|
||||
ErrVerificationApplicationNotFound = errors.New("verification application not found")
|
||||
// ErrVerificationApplicationExists reports an active application already
|
||||
// occupying the target.
|
||||
ErrVerificationApplicationExists = errors.New("verification application already active for target")
|
||||
// ErrVerificationApplicationInvalid rejects a malformed application.
|
||||
ErrVerificationApplicationInvalid = errors.New("verification application invalid")
|
||||
// ErrVerificationStatusInvalid rejects an impossible status transition.
|
||||
ErrVerificationStatusInvalid = errors.New("verification status transition invalid")
|
||||
// ErrVerificationVersionConflict reports a lost optimistic-locking race, which
|
||||
// is what two reviewers deciding at once must produce for the loser.
|
||||
ErrVerificationVersionConflict = errors.New("verification application changed concurrently")
|
||||
// ErrVerificationTargetInvalid rejects a target that cannot be verified.
|
||||
ErrVerificationTargetInvalid = errors.New("verification target invalid")
|
||||
// ErrVerificationTargetNotPublic rejects a target without a public username.
|
||||
ErrVerificationTargetNotPublic = errors.New("verification target has no public username")
|
||||
// ErrVerificationTargetAlreadyVerified rejects a target that already carries
|
||||
// the platform flag.
|
||||
ErrVerificationTargetAlreadyVerified = errors.New("verification target already verified")
|
||||
// ErrVerificationTargetRestricted rejects a scam/fake/frozen/deleted target.
|
||||
ErrVerificationTargetRestricted = errors.New("verification target restricted")
|
||||
// ErrVerificationTargetSystem rejects a built-in system entity.
|
||||
ErrVerificationTargetSystem = errors.New("verification target is a system entity")
|
||||
// ErrVerificationNotOwner reports that the applicant does not control the
|
||||
// target.
|
||||
ErrVerificationNotOwner = errors.New("applicant does not control verification target")
|
||||
// ErrVerificationReasonRequired reports a rejection or revocation without a
|
||||
// reason, which the audit trail must never contain.
|
||||
ErrVerificationReasonRequired = errors.New("verification decision reason required")
|
||||
// ErrVerificationCooldown reports a re-application filed before the
|
||||
// configured cooldown after a rejection has elapsed.
|
||||
ErrVerificationCooldown = errors.New("verification re-application cooldown active")
|
||||
// ErrVerificationRateLimited reports too many applications in the window.
|
||||
ErrVerificationRateLimited = errors.New("verification rate limit exceeded")
|
||||
// ErrVerificationURLInvalid rejects a link that is not a plain http(s) URL to
|
||||
// a public host.
|
||||
ErrVerificationURLInvalid = errors.New("verification link invalid")
|
||||
// ErrVerificationUserTargetsDisabled reports that plain user accounts are not
|
||||
// accepted by this deployment.
|
||||
ErrVerificationUserTargetsDisabled = errors.New("verification of user accounts is disabled")
|
||||
)
|
||||
|
||||
// VerificationApplication is the persistent application record.
|
||||
//
|
||||
// The target is addressed by its stable peer id; TargetTitle and TargetUsername
|
||||
// are a snapshot for the review UI and audit trail, because a username can move
|
||||
// between peers and a title can change after submission.
|
||||
type VerificationApplication struct {
|
||||
ID int64
|
||||
ApplicantUserID int64
|
||||
TargetType VerificationTargetType
|
||||
TargetID int64
|
||||
TargetTitle string
|
||||
TargetUsername string
|
||||
TargetAccessHash int64
|
||||
Category string
|
||||
Description string
|
||||
OfficialWebsite string
|
||||
SocialLinks []string
|
||||
PressLinks []string
|
||||
AdditionalNote string
|
||||
Status VerificationStatus
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
SubmittedAt time.Time
|
||||
ReviewedAt time.Time
|
||||
ReviewerAdminID string
|
||||
DecisionReason string
|
||||
InternalNote string
|
||||
CorrelationID string
|
||||
// Version is the optimistic-locking token. Every mutation submits the version
|
||||
// it read and the store refuses a stale one, which is how two reviewers
|
||||
// deciding the same application at the same time produce exactly one decision.
|
||||
Version int64
|
||||
}
|
||||
|
||||
// Target returns the peer the application is about.
|
||||
func (a VerificationApplication) Target() Peer {
|
||||
return Peer{Type: a.TargetType.PeerType(), ID: a.TargetID}
|
||||
}
|
||||
|
||||
// Editable reports whether the applicant may still change the payload.
|
||||
func (a VerificationApplication) Editable() bool {
|
||||
return a.Status == VerificationStatusDraft
|
||||
}
|
||||
|
||||
// VerificationApplicationEventKind is one entry of the immutable per-application
|
||||
// history rendered in the panel.
|
||||
type VerificationApplicationEventKind string
|
||||
|
||||
const (
|
||||
VerificationEventCreated VerificationApplicationEventKind = "created"
|
||||
VerificationEventUpdated VerificationApplicationEventKind = "updated"
|
||||
VerificationEventSubmitted VerificationApplicationEventKind = "submitted"
|
||||
VerificationEventClaimed VerificationApplicationEventKind = "claimed"
|
||||
VerificationEventApproved VerificationApplicationEventKind = "approved"
|
||||
VerificationEventRejected VerificationApplicationEventKind = "rejected"
|
||||
VerificationEventCancelled VerificationApplicationEventKind = "cancelled"
|
||||
VerificationEventRevoked VerificationApplicationEventKind = "revoked"
|
||||
VerificationEventNotified VerificationApplicationEventKind = "notified"
|
||||
)
|
||||
|
||||
// Valid reports whether the event kind is modelled.
|
||||
func (k VerificationApplicationEventKind) Valid() bool {
|
||||
switch k {
|
||||
case VerificationEventCreated, VerificationEventUpdated, VerificationEventSubmitted,
|
||||
VerificationEventClaimed, VerificationEventApproved, VerificationEventRejected,
|
||||
VerificationEventCancelled, VerificationEventRevoked, VerificationEventNotified:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// VerificationApplicationEvent is an append-only history row. Actor is the admin
|
||||
// identity for review actions and empty for applicant-driven ones, which are
|
||||
// attributed by ApplicantUserID on the application itself.
|
||||
type VerificationApplicationEvent struct {
|
||||
ID int64
|
||||
ApplicationID int64
|
||||
Kind VerificationApplicationEventKind
|
||||
FromStatus VerificationStatus
|
||||
ToStatus VerificationStatus
|
||||
Actor string
|
||||
Reason string
|
||||
Note string
|
||||
CorrelationID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// VerificationTarget is a candidate the applicant controls, as offered by the
|
||||
// bot's target picker.
|
||||
type VerificationTarget struct {
|
||||
Type VerificationTargetType
|
||||
ID int64
|
||||
Title string
|
||||
Username string
|
||||
AccessHash int64
|
||||
Verified bool
|
||||
// Eligible is false when the target cannot be filed right now; Reason carries
|
||||
// the domain error for the bot to explain why.
|
||||
Eligible bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// VerificationDraftInput is the applicant-supplied payload collected by the bot.
|
||||
type VerificationDraftInput struct {
|
||||
Category string
|
||||
Description string
|
||||
OfficialWebsite string
|
||||
SocialLinks []string
|
||||
PressLinks []string
|
||||
AdditionalNote string
|
||||
}
|
||||
|
||||
// Normalize trims the payload and drops empty links. It is applied before
|
||||
// validation so a trailing newline from a chat message is not an error.
|
||||
func (in VerificationDraftInput) Normalize() VerificationDraftInput {
|
||||
out := VerificationDraftInput{
|
||||
Category: strings.TrimSpace(in.Category),
|
||||
Description: strings.TrimSpace(in.Description),
|
||||
OfficialWebsite: strings.TrimSpace(in.OfficialWebsite),
|
||||
AdditionalNote: strings.TrimSpace(in.AdditionalNote),
|
||||
}
|
||||
out.SocialLinks = normalizeVerificationLinks(in.SocialLinks)
|
||||
out.PressLinks = normalizeVerificationLinks(in.PressLinks)
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeVerificationLinks(links []string) []string {
|
||||
out := make([]string, 0, len(links))
|
||||
seen := make(map[string]struct{}, len(links))
|
||||
for _, link := range links {
|
||||
link = strings.TrimSpace(link)
|
||||
if link == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(link)
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, link)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ValidateDraft checks a partially filled draft: every present field must be
|
||||
// well formed, but nothing is required yet. The bot uses it per step.
|
||||
func (in VerificationDraftInput) ValidateDraft() error {
|
||||
in = in.Normalize()
|
||||
if in.Category != "" && !ValidVerificationCategory(in.Category) {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(in.Description) > MaxVerificationDescriptionLength {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(in.AdditionalNote) > MaxVerificationCommentLength {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if in.OfficialWebsite != "" {
|
||||
if err := ValidateVerificationURL(in.OfficialWebsite); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(in.SocialLinks) > MaxVerificationSocialLinks || len(in.PressLinks) > MaxVerificationPressLinks {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
for _, link := range append(append([]string(nil), in.SocialLinks...), in.PressLinks...) {
|
||||
if err := ValidateVerificationURL(link); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateForSubmission checks a complete application. Everything the official
|
||||
// process requires must be present: category, a real description, a website, and
|
||||
// at least MinVerificationPressLinks independent press links.
|
||||
func (in VerificationDraftInput) ValidateForSubmission() error {
|
||||
in = in.Normalize()
|
||||
if err := in.ValidateDraft(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !ValidVerificationCategory(in.Category) {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(in.Description) < MinVerificationDescriptionLength {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if in.OfficialWebsite == "" {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if len(in.PressLinks) < MinVerificationPressLinks {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateVerificationURL accepts only a plain http(s) URL naming a public host.
|
||||
//
|
||||
// The server never fetches these links, and this check is what keeps that
|
||||
// decision safe to revisit: credentials, non-web schemes, loopback, link-local,
|
||||
// private and other reserved address space are all refused, so a link can never
|
||||
// become an SSRF probe against the deployment's own network, and the admin panel
|
||||
// only ever renders an absolute external URL.
|
||||
func ValidateVerificationURL(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || len(raw) > MaxVerificationURLLength {
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
if strings.ContainsAny(raw, " \t\r\n<>\"'\\") {
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "http", "https":
|
||||
default:
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
if parsed.User != nil {
|
||||
// user:password@host would be rendered as a credential-bearing link.
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
if err := validateVerificationHost(host); err != nil {
|
||||
return err
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
switch port {
|
||||
case "80", "443":
|
||||
default:
|
||||
// A non-standard port on a "public site" link is a smell and would be
|
||||
// the first thing an SSRF attempt reaches for.
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateVerificationHost(host string) error {
|
||||
lower := strings.ToLower(host)
|
||||
if lower == "localhost" || strings.HasSuffix(lower, ".localhost") ||
|
||||
strings.HasSuffix(lower, ".local") || strings.HasSuffix(lower, ".internal") ||
|
||||
strings.HasSuffix(lower, ".onion") {
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if !publicVerificationIP(ip) {
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// A registered name must look like a real domain: at least one dot, no
|
||||
// underscores, no trailing dot-only labels.
|
||||
if !strings.Contains(lower, ".") {
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
for _, label := range strings.Split(strings.TrimSuffix(lower, "."), ".") {
|
||||
if label == "" || len(label) > 63 {
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
for i := 0; i < len(label); i++ {
|
||||
c := label[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z':
|
||||
case c >= '0' && c <= '9':
|
||||
case c == '-' && i != 0 && i != len(label)-1:
|
||||
default:
|
||||
return ErrVerificationURLInvalid
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publicVerificationIP reports whether an IP literal is globally routable.
|
||||
func publicVerificationIP(ip net.IP) bool {
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() || ip.IsPrivate() {
|
||||
return false
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
switch {
|
||||
case v4[0] == 100 && v4[1]&0xc0 == 64: // 100.64.0.0/10 CGNAT
|
||||
return false
|
||||
case v4[0] == 192 && v4[1] == 0 && v4[2] == 0: // 192.0.0.0/24
|
||||
return false
|
||||
case v4[0] == 192 && v4[1] == 0 && v4[2] == 2: // TEST-NET-1
|
||||
return false
|
||||
case v4[0] == 198 && v4[1] == 51 && v4[2] == 100: // TEST-NET-2
|
||||
return false
|
||||
case v4[0] == 203 && v4[1] == 0 && v4[2] == 113: // TEST-NET-3
|
||||
return false
|
||||
case v4[0] == 198 && v4[1]&0xfe == 18: // 198.18.0.0/15 benchmarking
|
||||
return false
|
||||
case v4[0] >= 240: // 240.0.0.0/4 reserved, 255.255.255.255 broadcast
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// IPv6: refuse unique-local, documentation, and v4-mapped forms that would
|
||||
// smuggle a private v4 address past the checks above.
|
||||
if len(ip) == net.IPv6len {
|
||||
if ip[0]&0xfe == 0xfc { // fc00::/7
|
||||
return false
|
||||
}
|
||||
if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x0d && ip[3] == 0xb8 { // 2001:db8::/32
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SubmitVerificationApplicationRequest is the applicant-side submission.
|
||||
type SubmitVerificationApplicationRequest struct {
|
||||
ApplicantUserID int64
|
||||
TargetType VerificationTargetType
|
||||
TargetID int64
|
||||
TargetTitle string
|
||||
TargetUsername string
|
||||
Draft VerificationDraftInput
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// Validate checks the request shape without touching the target state, which is
|
||||
// the service's job.
|
||||
func (r SubmitVerificationApplicationRequest) Validate() error {
|
||||
if r.ApplicantUserID <= 0 || r.TargetID <= 0 || !r.TargetType.Valid() {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(r.TargetTitle) > MaxVerificationTitleLength {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if len(r.CorrelationID) > MaxVerificationCorrelationLen {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
return r.Draft.ValidateForSubmission()
|
||||
}
|
||||
|
||||
// VerificationDecision is a reviewer's action on one application.
|
||||
type VerificationDecision struct {
|
||||
ApplicationID int64
|
||||
// Version is the value the reviewer read; a mismatch is a concurrent decision.
|
||||
Version int64
|
||||
Reviewer string
|
||||
Reason string
|
||||
InternalNote string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// Validate checks a decision that does not require a reason.
|
||||
func (d VerificationDecision) Validate() error {
|
||||
if d.ApplicationID <= 0 || d.Version <= 0 {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if strings.TrimSpace(d.Reviewer) == "" || len(d.Reviewer) > MaxVerificationReviewerLength {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(d.Reason) > MaxVerificationReasonLength ||
|
||||
utf8.RuneCountInString(d.InternalNote) > MaxVerificationNoteLength {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if len(d.CorrelationID) > MaxVerificationCorrelationLen {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateWithReason checks a decision that must carry one: rejection and
|
||||
// revocation are never recorded without a stated reason.
|
||||
func (d VerificationDecision) ValidateWithReason() error {
|
||||
if err := d.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(d.Reason) == "" {
|
||||
return ErrVerificationReasonRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerificationApplicationFilter bounds a review-queue query.
|
||||
type VerificationApplicationFilter struct {
|
||||
Statuses []VerificationStatus
|
||||
TargetType VerificationTargetType
|
||||
Reviewer string
|
||||
// Query matches an application id, a peer id or a username.
|
||||
Query string
|
||||
CreatedAt time.Time
|
||||
Until time.Time
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
// VerificationStatusCounts is the queue summary shown above the list.
|
||||
type VerificationStatusCounts map[VerificationStatus]int64
|
||||
|
||||
// VerificationRevocation removes the platform flag from a previously approved
|
||||
// target. It is modelled separately from a rejection: the application stays
|
||||
// approved as history, and the revocation is its own audit event.
|
||||
type VerificationRevocation struct {
|
||||
TargetType VerificationTargetType
|
||||
TargetID int64
|
||||
Reviewer string
|
||||
Reason string
|
||||
InternalNote string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// Validate checks the revocation shape; a reason is mandatory.
|
||||
func (r VerificationRevocation) Validate() error {
|
||||
if r.TargetID <= 0 || !r.TargetType.Valid() {
|
||||
return ErrVerificationTargetInvalid
|
||||
}
|
||||
if strings.TrimSpace(r.Reviewer) == "" || len(r.Reviewer) > MaxVerificationReviewerLength {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
if strings.TrimSpace(r.Reason) == "" {
|
||||
return ErrVerificationReasonRequired
|
||||
}
|
||||
if utf8.RuneCountInString(r.Reason) > MaxVerificationReasonLength ||
|
||||
utf8.RuneCountInString(r.InternalNote) > MaxVerificationNoteLength {
|
||||
return ErrVerificationApplicationInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue