Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions

View file

@ -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.

View 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
}

View 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
}

View 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
}

View file

@ -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

View 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
}

View 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)
}
}

View 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 = &current[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
}

View file

@ -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

View file

@ -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

View file

@ -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"`

View file

@ -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
}
}

View file

@ -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")

View 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
}

View 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
}

View 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
}

View 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)
}
}

View 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
}

View file

@ -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:

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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

View 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
}