feat: add NFT usernames and bot verification (#22)

Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
Egor Egorov 2026-07-27 20:18:00 +03:00 • committed by GitHub
parent b0fd3976f1
commit fff8de783a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 55769 additions and 282 deletions

View file

@ -0,0 +1,398 @@
package domain
import (
"errors"
"time"
)
// Composite account rating.
//
// This is a server-local moderation/operations score for the admin panel. It is
// deliberately not Telegram's Stars Rating: the official field describes Stars
// transaction volume, whereas this model combines Stars, account activity and
// moderation penalties. Projecting it into userFull.stars_rating would give
// official clients a materially false meaning, so the RPC edge keeps those
// fields unset.
const (
// MaxAccountRatingLevel bounds the local admin 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 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 local admin-facing 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 admin 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,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

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

@ -21,6 +21,28 @@ const (
ChatBotUserID int64 = 1250000007
// ChatBotAccessHash 固定不变;与 postgres 种子行双写,必须保持一致。
ChatBotAccessHash int64 = 6332902371644871201
// 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
)
// OfficialSystemUser 返回第一阶段内置的官方系统账号。
@ -75,6 +97,38 @@ func ChatBotUser() User {
}
}
// 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) {
@ -87,6 +141,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
}
@ -96,9 +154,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

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