package memory import ( "context" "fmt" "sort" "strconv" "strings" "sync" "time" "telesrv/internal/domain" "telesrv/internal/store" ) // Default page sizes applied when a caller leaves the limit unset. The // PostgreSQL queries page with LIMIT, so an unset limit has to resolve to the // same finite page in both backends. const ( defaultBotVerificationListLimit = 50 maxBotVerificationListLimit = 200 // The bounds below mirror the octet_length CHECKs of 0155. They are not // redundant with the domain Validate methods: the domain counts runes and the // columns count bytes, so multi-byte text can clear validation and still // violate a CHECK. Guarding here is what keeps this backend from accepting // payloads the PostgreSQL one would reject. maxVerificationIconNameBytes = 512 maxVerifierCompanyBytes = 512 maxVerifierDescriptionBytes = 280 maxVerifierGrantReasonBytes = 4096 maxCustomVerificationDescriptionBytes = 4096 maxCustomVerificationInputBytes = 280 maxCustomVerificationTitleBytes = 1024 maxCustomVerificationUsernameBytes = 64 maxCustomVerificationReasonBytes = 16384 maxCustomVerificationDecidedByBytes = 128 maxCustomVerificationDecisionBytes = 4096 maxCustomVerificationNoteBytes = 32768 maxCustomVerificationCorrelationBytes = 128 ) // BotVerificationStore is the in-memory implementation of // store.BotVerificationStore. The RPC, bot and admin unit tests run against it, // so it reproduces every invariant migration 0155 encodes as an index, a CHECK or // a transaction boundary, and returns the same domain errors the PostgreSQL // backend maps its violations onto: // // - verification_icons.document_id UNIQUE: the catalogue is keyed by document, // so a second upsert of the same document edits the entry in place. // - custom_verifications_peer_once: a peer has at most one wire-visible mark; // another verifier replaces it instead of creating hidden fallback state. // - domain.MaxCustomVerificationsPerVerifier: checked before a mark is created, // never on the update path, so an existing mark can always be re-described. // - the projection: the peer's single mark projects only while its verifier is // enabled, which is the operator kill switch. // - custom_verifications.verifier_bot_id ON DELETE CASCADE: deleting verifier // status takes its marks with it, while applications survive as history // because they reference users instead. // - custom_verification_requests_pending_idx: one live application per // (verifier, peer); a second one is // domain.ErrCustomVerificationRequestExists. // - the approved_at / rejected_at CHECKs: each stamp is paired with its status, // which is why a revocation clears approved_at as it leaves that state. // - the decision transaction: the apply callback runs against a state snapshot, // so a failing callback restores the store exactly as it was and "approved // implies the mark exists" holds here too. // // Status transitions all go through // domain.CanTransitionCustomVerificationStatus; this file never re-implements the // machine. Like the PostgreSQL backend it does not validate icon ids against the // catalogue and does not refuse a grant by a disabled verifier: "may this bot // verify?" is an RPC-edge decision made from the settings this store returns. type BotVerificationStore struct { mu sync.Mutex // decideMu serialises decisions. A decision releases mu around its apply // callback -- the callback grants or revokes the mark through this very // store, so holding mu would deadlock -- and decideMu is what keeps two // decisions from interleaving inside that window. decideMu sync.Mutex nextIconID int64 nextMarkID int64 nextRequestID int64 icons map[int64]domain.VerificationIcon iconsByDocument map[int64]int64 verifiers map[int64]domain.BotVerifierSettings marks map[int64]domain.CustomVerification marksByPeer map[customVerificationKey]int64 // markCounts is the per-verifier mark count the bound is checked against, // maintained incrementally so a verifier close to // domain.MaxCustomVerificationsPerVerifier does not turn every grant into a // full scan. markCounts map[int64]int requests map[int64]domain.CustomVerificationRequest // lastNow keeps the store's clock strictly increasing, so orderings that tie // on a timestamp are as deterministic as they are against PostgreSQL. lastNow time.Time } // customVerificationKey is keyed by peer. verifierBotID is retained in the type // only to keep snapshots and helper call sites compact; customVerificationKeyOf // deliberately normalizes it to zero. type customVerificationKey struct { verifierBotID int64 peerType domain.PeerType peerID int64 } // NewBotVerificationStore creates an empty store. Ids start at 1 so a zero ID // keeps meaning "no row". func NewBotVerificationStore() *BotVerificationStore { return &BotVerificationStore{ nextIconID: 1, nextMarkID: 1, nextRequestID: 1, icons: make(map[int64]domain.VerificationIcon), iconsByDocument: make(map[int64]int64), verifiers: make(map[int64]domain.BotVerifierSettings), marks: make(map[int64]domain.CustomVerification), marksByPeer: make(map[customVerificationKey]int64), markCounts: make(map[int64]int), requests: make(map[int64]domain.CustomVerificationRequest), } } var _ store.BotVerificationStore = (*BotVerificationStore)(nil) // ---- icon catalogue --------------------------------------------------------- // UpsertVerificationIcon adds or updates a catalogue entry. document_id is the // identity of an entry, not icon.ID, and Active travels with the payload: // SetVerificationIconActive is the narrow path for flipping only that flag. func (s *BotVerificationStore) UpsertVerificationIcon(_ context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error) { icon.Name = strings.TrimSpace(icon.Name) if err := icon.Validate(); err != nil { return domain.VerificationIcon{}, err } if len(icon.Name) > maxVerificationIconNameBytes { return domain.VerificationIcon{}, domain.ErrVerificationIconInvalid } s.mu.Lock() defer s.mu.Unlock() now := s.nowLocked() if id, ok := s.iconsByDocument[icon.DocumentID]; ok { current := s.icons[id] current.OwnerBotID = icon.OwnerBotID current.Name = icon.Name current.Active = icon.Active current.UpdatedAt = laterVerificationTime(current.UpdatedAt, now) s.icons[id] = current return current, nil } stored := domain.VerificationIcon{ ID: s.nextIconID, DocumentID: icon.DocumentID, OwnerBotID: icon.OwnerBotID, Name: icon.Name, Active: icon.Active, CreatedAt: now, UpdatedAt: now, } s.nextIconID++ s.icons[stored.ID] = stored s.iconsByDocument[stored.DocumentID] = stored.ID return stored, nil } // SetVerificationIconActive retires or restores an entry. Marks already granted // with it keep rendering: the icon id is denormalised onto the mark. func (s *BotVerificationStore) SetVerificationIconActive(_ context.Context, iconID int64, active bool) (domain.VerificationIcon, error) { if iconID <= 0 { return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound } s.mu.Lock() defer s.mu.Unlock() icon, ok := s.icons[iconID] if !ok { return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound } icon.Active = active icon.UpdatedAt = laterVerificationTime(icon.UpdatedAt, s.nowLocked()) s.icons[iconID] = icon return icon, nil } // VerificationIcon reads one entry by id. func (s *BotVerificationStore) VerificationIcon(_ context.Context, iconID int64) (domain.VerificationIcon, error) { if iconID <= 0 { return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound } s.mu.Lock() defer s.mu.Unlock() icon, ok := s.icons[iconID] if !ok { return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound } return icon, nil } // VerificationIconByDocument reads one entry by its custom emoji document id, // which is how the admin edge resolves an icon a verifier already carries. func (s *BotVerificationStore) VerificationIconByDocument(_ context.Context, documentID int64) (domain.VerificationIcon, error) { if documentID <= 0 { return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound } s.mu.Lock() defer s.mu.Unlock() id, ok := s.iconsByDocument[documentID] if !ok { return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound } return s.icons[id], nil } // ListVerificationIcons lists the catalogue, newest first. func (s *BotVerificationStore) ListVerificationIcons(_ context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error) { limit = botVerificationLimit(limit) s.mu.Lock() defer s.mu.Unlock() ids := sortedBotVerificationIDs(s.icons, false) out := make([]domain.VerificationIcon, 0, limit) for _, id := range ids { if len(out) == limit { break } icon := s.icons[id] if activeOnly && !icon.Active { continue } out = append(out, icon) } return out, nil } // ---- verifier status -------------------------------------------------------- // UpsertBotVerifierSettings grants or updates verifier status. // // settings.Version is the optimistic-locking expectation: 0 means "there is no // verifier row yet" and a stored row then reports // domain.ErrCustomVerificationVersionConflict, and a non-zero version must match // the stored one. CreatedAt is never rewritten, so the grant date survives every // later edit. func (s *BotVerificationStore) UpsertBotVerifierSettings(_ context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error) { settings.CompanyName = strings.TrimSpace(settings.CompanyName) settings.DefaultDescription = strings.TrimSpace(settings.DefaultDescription) settings.GrantedBy = strings.TrimSpace(settings.GrantedBy) settings.GrantReason = strings.TrimSpace(settings.GrantReason) if err := settings.Validate(); err != nil { return domain.BotVerifierSettings{}, err } if settings.Version < 0 || !botVerifierSettingsColumnsFit(settings) { return domain.BotVerifierSettings{}, domain.ErrVerifierSettingsInvalid } s.mu.Lock() defer s.mu.Unlock() now := s.nowLocked() current, ok := s.verifiers[settings.BotID] if !ok { if settings.Version != 0 { // The caller edits a row that is no longer there. return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound } stored := settings stored.CreatedAt = now stored.UpdatedAt = now stored.Version = 1 s.verifiers[stored.BotID] = stored return stored, nil } if settings.Version == 0 || settings.Version != current.Version { return domain.BotVerifierSettings{}, domain.ErrCustomVerificationVersionConflict } updated := settings updated.CreatedAt = current.CreatedAt updated.UpdatedAt = laterVerificationTime(current.UpdatedAt, now) updated.Version = current.Version + 1 s.verifiers[updated.BotID] = updated return updated, nil } // SetBotVerifierEnabled flips the operator kill switch. Existing marks stay, but // the verifier can grant nothing new and neither its settings nor its marks are // projected, so flipping the switch back restores exactly what was there. // Setting the flag to the value it already has is a no-op and does not burn a // version. func (s *BotVerificationStore) SetBotVerifierEnabled(_ context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error) { if botID <= 0 { return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound } s.mu.Lock() defer s.mu.Unlock() current, ok := s.verifiers[botID] if !ok { return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound } if current.Enabled == enabled { return current, nil } current.Enabled = enabled current.UpdatedAt = laterVerificationTime(current.UpdatedAt, s.nowLocked()) current.Version++ s.verifiers[botID] = current return current, nil } // DeleteBotVerifierSettings removes verifier status. Its marks cascade away with // it, because a mark whose verifier no longer exists has nothing to render. // Applications survive: they reference users, not the verifier row, and stay as // history. func (s *BotVerificationStore) DeleteBotVerifierSettings(_ context.Context, botID int64) (bool, error) { if botID <= 0 { return false, domain.ErrVerifierNotFound } s.mu.Lock() defer s.mu.Unlock() if _, ok := s.verifiers[botID]; !ok { return false, nil } delete(s.verifiers, botID) for id, mark := range s.marks { if mark.VerifierBotID != botID { continue } delete(s.marks, id) delete(s.marksByPeer, customVerificationKeyOf(mark.VerifierBotID, mark.Peer)) } delete(s.markCounts, botID) return true, nil } // BotVerifierSettings reads one verifier's status, enabled or not: the caller // needs the disabled row too, to render the kill switch and to explain // BOT_VERIFIER_FORBIDDEN. func (s *BotVerificationStore) BotVerifierSettings(_ context.Context, botID int64) (domain.BotVerifierSettings, error) { if botID <= 0 { return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound } s.mu.Lock() defer s.mu.Unlock() settings, ok := s.verifiers[botID] if !ok { return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound } return settings, nil } // BotVerifierSettingsBatch resolves several bots at once for the botInfo // projection; bots without verifier status are absent from the map. Disabled // verifiers are returned like any other row -- the Enabled field says so, and the // projection edge decides -- which mirrors ListBotVerifiers taking enabledOnly as // an explicit argument instead of assuming it. func (s *BotVerificationStore) BotVerifierSettingsBatch(_ context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error) { out := make(map[int64]domain.BotVerifierSettings, len(botIDs)) s.mu.Lock() defer s.mu.Unlock() for _, id := range botIDs { if id <= 0 { continue } if settings, ok := s.verifiers[id]; ok { out[id] = settings } } return out, nil } // ListBotVerifiers lists verifier bots for the admin panel, ordered by bot id. func (s *BotVerificationStore) ListBotVerifiers(_ context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) { limit = botVerificationLimit(limit) s.mu.Lock() defer s.mu.Unlock() ids := sortedBotVerificationIDs(s.verifiers, true) out := make([]domain.BotVerifierSettings, 0, limit) for _, id := range ids { if len(out) == limit { break } settings := s.verifiers[id] if enabledOnly && !settings.Enabled { continue } out = append(out, settings) } return out, nil } // ---- granted marks --------------------------------------------------------- // GrantCustomVerification creates or updates this verifier's mark on the peer. // // The peer is the identity of a mark. A second grant by the same verifier is an // update reported with created=false; a different verifier replaces the mark and // is reported as a new grant. // // mark.IconDocumentID is denormalised from the verifier's settings when the // caller leaves it unset, which is what "the icon is taken from the verifier at // grant time" means; an explicit id is honoured. func (s *BotVerificationStore) GrantCustomVerification(_ context.Context, mark domain.CustomVerification) (domain.CustomVerification, bool, error) { mark.Description = strings.TrimSpace(mark.Description) if mark.VerifierBotID <= 0 || !validBotVerificationPeer(mark.Peer) { return domain.CustomVerification{}, false, domain.ErrCustomVerificationTargetInvalid } if mark.GrantedByUserID < 0 { return domain.CustomVerification{}, false, domain.ErrCustomVerificationTargetInvalid } if len(mark.Description) > maxCustomVerificationDescriptionBytes { return domain.CustomVerification{}, false, domain.ErrCustomVerificationRequestInvalid } s.mu.Lock() defer s.mu.Unlock() return s.grantLocked(mark) } // RevokeCustomVerification removes this verifier's mark from the peer and // reports whether anything was removed, so a repeated revoke is a no-op instead // of an error. Only this verifier's mark goes: another verifier's mark on the // same peer is none of its business. func (s *BotVerificationStore) RevokeCustomVerification(_ context.Context, verifierBotID int64, peer domain.Peer) (bool, error) { if verifierBotID <= 0 || !validBotVerificationPeer(peer) { return false, domain.ErrCustomVerificationTargetInvalid } s.mu.Lock() defer s.mu.Unlock() return s.revokeLocked(verifierBotID, peer), nil } // CustomVerification reads one verifier's mark on a peer, whether or not that // verifier is currently enabled: this is the bookkeeping read, not the // projection. func (s *BotVerificationStore) CustomVerification(_ context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerification, error) { if verifierBotID <= 0 || !validBotVerificationPeer(peer) { return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound } s.mu.Lock() defer s.mu.Unlock() id, ok := s.marksByPeer[customVerificationKeyOf(verifierBotID, peer)] if !ok { return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound } mark := s.marks[id] if mark.VerifierBotID != verifierBotID { return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound } return mark, nil } // PeerVerification returns the peer's single mark when its verifier is enabled. func (s *BotVerificationStore) PeerVerification(_ context.Context, peer domain.Peer) (domain.CustomVerification, error) { if !validBotVerificationPeer(peer) { return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound } s.mu.Lock() defer s.mu.Unlock() mark, found := s.projectedMarkLocked(peer) if !found { return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound } return mark, nil } // PeerVerificationBatch resolves the projection for many peers at once. This is // the call on the hot serialisation path, so peers without a mark are simply // absent instead of erroring, and every peer resolves through the same // same enabled-verifier rule PeerVerification uses. func (s *BotVerificationStore) PeerVerificationBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error) { out := make(map[domain.Peer]domain.CustomVerification, len(peers)) s.mu.Lock() defer s.mu.Unlock() for _, peer := range peers { if !validBotVerificationPeer(peer) { continue } if _, done := out[peer]; done { continue } if mark, found := s.projectedMarkLocked(peer); found { out[peer] = mark } } return out, nil } // CountCustomVerifications reports how many peers a verifier has marked, for the // per-verifier bound. Disabled verifiers still count their marks: the switch // hides badges, it does not free quota. func (s *BotVerificationStore) CountCustomVerifications(_ context.Context, verifierBotID int64) (int, error) { if verifierBotID <= 0 { return 0, domain.ErrCustomVerificationTargetInvalid } s.mu.Lock() defer s.mu.Unlock() return s.markCounts[verifierBotID], nil } // ListCustomVerifications is the admin listing query with keyset paging over id // DESC (filter.BeforeID carries the last row of the previous page). Query matches // a mark id or a peer id when it is numeric and otherwise matches the // description case-insensitively, the only text a mark carries. func (s *BotVerificationStore) ListCustomVerifications(_ context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) { if filter.PeerType != "" && !botVerificationPeerType(filter.PeerType) { return nil, domain.ErrCustomVerificationTargetInvalid } limit := botVerificationLimit(filter.Limit) numeric, isNumeric, needle := parseBotVerificationQuery(filter.Query) s.mu.Lock() defer s.mu.Unlock() out := make([]domain.CustomVerification, 0, limit) for _, id := range sortedBotVerificationIDs(s.marks, false) { if len(out) == limit { break } mark := s.marks[id] if filter.VerifierBotID != 0 && mark.VerifierBotID != filter.VerifierBotID { continue } if filter.PeerType != "" && mark.Peer.Type != filter.PeerType { continue } if filter.PeerID != 0 && mark.Peer.ID != filter.PeerID { continue } if filter.BeforeID != 0 && mark.ID >= filter.BeforeID { continue } if isNumeric && mark.ID != numeric && mark.Peer.ID != numeric { continue } if needle != "" && !strings.Contains(strings.ToLower(mark.Description), needle) { continue } out = append(out, mark) } return out, nil } // ---- application queue ----------------------------------------------------- // CreateCustomVerificationRequest files an application. // // A filed application is pending by definition, so the status is forced and any // decision field the caller pre-filled is dropped: only // DecideCustomVerificationRequest may write those. One live application per // (verifier, peer) is allowed, and a second one reports // domain.ErrCustomVerificationRequestExists -- two pending rows would let two // decisions race for one mark. func (s *BotVerificationStore) CreateCustomVerificationRequest(_ context.Context, req domain.CustomVerificationRequest) (domain.CustomVerificationRequest, error) { req = normalizeCustomVerificationRequest(req) if req.Status == "" { req.Status = domain.CustomVerificationPending } if req.Status != domain.CustomVerificationPending { return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestInvalid } req.DecidedBy = "" req.DecisionReason = "" if err := req.Validate(); err != nil { return domain.CustomVerificationRequest{}, err } if err := validateCustomVerificationRequestColumns(req); err != nil { return domain.CustomVerificationRequest{}, err } s.mu.Lock() defer s.mu.Unlock() if _, found := s.pendingRequestLocked(req.VerifierBotID, req.Peer); found { return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestExists } now := s.nowLocked() stored := req stored.ID = s.nextRequestID stored.ApprovedAt = time.Time{} stored.RejectedAt = time.Time{} stored.CreatedAt = now stored.UpdatedAt = now stored.Version = 1 s.nextRequestID++ s.requests[stored.ID] = stored return stored, nil } // DecideCustomVerificationRequest moves an application through its status // machine and keeps the mark in step with it. // // The whole decision is one unit: the status change, the decision metadata, the // approved/rejected stamps and the apply callback that grants (status approved) // or removes (status revoked) the mark. Nothing survives a callback error -- // the store is snapshotted before the callback runs and restored if it fails, // including whatever the callback itself wrote -- which is the in-memory // equivalent of the PostgreSQL transaction and the reason "approved without a // mark" is not a reachable state. // // The version is compared first, so the loser of a race between two reviewers // always sees domain.ErrCustomVerificationVersionConflict. A caller re-issuing a // decision that already holds gets the request back with changed=false, no // second stamp and no second apply -- the callback is not idempotent by // assumption. // // The transition itself is domain.CanTransitionCustomVerificationStatus, never // re-implemented here, and the resulting row is validated with // domain.CustomVerificationRequest.Validate, which is what rejects a rejection // with no reason (domain.ErrVerificationReasonRequired). // // Isolation is deliberately narrower than PostgreSQL's: decisions serialise on // decideMu, but a concurrent reader can observe the decided row while apply is // still running, and an unrelated write landing in that window is undone by a // rollback. Unit tests drive this store from one goroutine; the PostgreSQL // backend is where real isolation lives. func (s *BotVerificationStore) DecideCustomVerificationRequest(ctx context.Context, requestID int64, version int64, status domain.CustomVerificationRequestStatus, decidedBy, reason, note string, apply func(ctx context.Context, req domain.CustomVerificationRequest) error) (domain.CustomVerificationRequest, bool, error) { decidedBy = strings.TrimSpace(decidedBy) reason = strings.TrimSpace(reason) note = strings.TrimSpace(note) if requestID <= 0 || version <= 0 { return domain.CustomVerificationRequest{}, false, domain.ErrCustomVerificationRequestInvalid } if !status.Valid() || status == domain.CustomVerificationPending { // Pending is where an application starts, not a decision anybody makes. return domain.CustomVerificationRequest{}, false, domain.ErrCustomVerificationRequestInvalid } if customVerificationDecisionNeedsApply(status) && apply == nil { // Deciding without a way to move the mark is exactly the state this store // exists to make impossible. return domain.CustomVerificationRequest{}, false, fmt.Errorf("custom verification decision %q requires an apply callback", status) } s.decideMu.Lock() defer s.decideMu.Unlock() s.mu.Lock() current, ok := s.requests[requestID] if !ok { s.mu.Unlock() return domain.CustomVerificationRequest{}, false, domain.ErrCustomVerificationRequestNotFound } if current.Version != version { s.mu.Unlock() return domain.CustomVerificationRequest{}, false, domain.ErrCustomVerificationVersionConflict } if current.Status == status { // Already decided this way: keep the record and report that nothing moved, // so a retried decision cannot apply the mark twice. s.mu.Unlock() return current, false, nil } if !domain.CanTransitionCustomVerificationStatus(current.Status, status) { s.mu.Unlock() return domain.CustomVerificationRequest{}, false, domain.ErrCustomVerificationRequestInvalid } updated := customVerificationDecisionState(current, status, decidedBy, reason, note, s.nowLocked()) if err := updated.Validate(); err != nil { s.mu.Unlock() return domain.CustomVerificationRequest{}, false, err } if err := validateCustomVerificationRequestColumns(updated); err != nil { s.mu.Unlock() return domain.CustomVerificationRequest{}, false, err } snapshot := s.snapshotLocked() s.requests[requestID] = updated // The callback grants or revokes the mark through this same store, so the // lock has to be released around it; decideMu still holds other decisions off. s.mu.Unlock() if customVerificationDecisionNeedsApply(status) { if err := apply(ctx, updated); err != nil { s.mu.Lock() s.restoreLocked(snapshot) s.mu.Unlock() return domain.CustomVerificationRequest{}, false, err } } return updated, true, nil } // CustomVerificationRequest reads one application. func (s *BotVerificationStore) CustomVerificationRequest(_ context.Context, requestID int64) (domain.CustomVerificationRequest, error) { if requestID <= 0 { return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound } s.mu.Lock() defer s.mu.Unlock() req, ok := s.requests[requestID] if !ok { return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound } return req, nil } // PendingCustomVerificationRequest returns the live application for a // (verifier, peer) pair. There is at most one, so no ordering is needed to pick // it. func (s *BotVerificationStore) PendingCustomVerificationRequest(_ context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerificationRequest, error) { if verifierBotID <= 0 || !validBotVerificationPeer(peer) { return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound } s.mu.Lock() defer s.mu.Unlock() req, found := s.pendingRequestLocked(verifierBotID, peer) if !found { return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound } return req, nil } // ListCustomVerificationRequests is the review-queue query with keyset paging // over id DESC: the filter carries no timestamp cursor, and ids are monotonic, so // id DESC is the same page order as created_at DESC without the ambiguity a mixed // cursor would have. Query matches an application id or a peer id when it is // numeric and otherwise prefix-matches the lowercased username snapshot. func (s *BotVerificationStore) ListCustomVerificationRequests(_ context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) { for _, status := range filter.Statuses { if !status.Valid() { return nil, domain.ErrCustomVerificationRequestInvalid } } if filter.PeerType != "" && !botVerificationPeerType(filter.PeerType) { return nil, domain.ErrCustomVerificationTargetInvalid } limit := botVerificationLimit(filter.Limit) numeric, isNumeric, prefix := parseBotVerificationQuery(filter.Query) s.mu.Lock() defer s.mu.Unlock() out := make([]domain.CustomVerificationRequest, 0, limit) for _, id := range sortedBotVerificationIDs(s.requests, false) { if len(out) == limit { break } req := s.requests[id] if len(filter.Statuses) > 0 && !containsCustomVerificationStatus(filter.Statuses, req.Status) { continue } if filter.VerifierBotID != 0 && req.VerifierBotID != filter.VerifierBotID { continue } if filter.PeerType != "" && req.Peer.Type != filter.PeerType { continue } if filter.BeforeID != 0 && req.ID >= filter.BeforeID { continue } if isNumeric && req.ID != numeric && req.Peer.ID != numeric { continue } if prefix != "" && (req.PeerUsername == "" || !strings.HasPrefix(strings.ToLower(req.PeerUsername), prefix)) { continue } out = append(out, req) } return out, nil } // CustomVerificationRequestsForApplicant returns an applicant's own history, // newest first, for the verifier bot's /status command. func (s *BotVerificationStore) CustomVerificationRequestsForApplicant(_ context.Context, applicantUserID int64, limit int) ([]domain.CustomVerificationRequest, error) { if applicantUserID <= 0 { return nil, domain.ErrCustomVerificationRequestInvalid } limit = botVerificationLimit(limit) s.mu.Lock() defer s.mu.Unlock() out := make([]domain.CustomVerificationRequest, 0, limit) for _, id := range sortedBotVerificationIDs(s.requests, false) { if len(out) == limit { break } if req := s.requests[id]; req.ApplicantUserID == applicantUserID { out = append(out, req) } } return out, nil } // CustomVerificationRequestCounts is the queue summary by status. Statuses // nobody is in are absent rather than zero, which a map read cannot tell apart // anyway. func (s *BotVerificationStore) CustomVerificationRequestCounts(_ context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) { s.mu.Lock() defer s.mu.Unlock() out := make(map[domain.CustomVerificationRequestStatus]int64, 4) for _, req := range s.requests { out[req.Status]++ } return out, nil } // ---- locked helpers --------------------------------------------------------- // grantLocked is the upsert both the public grant and a decision's apply // callback end up in. func (s *BotVerificationStore) grantLocked(mark domain.CustomVerification) (domain.CustomVerification, bool, error) { settings, ok := s.verifiers[mark.VerifierBotID] if !ok { return domain.CustomVerification{}, false, domain.ErrVerifierNotFound } if mark.IconDocumentID <= 0 { mark.IconDocumentID = settings.IconDocumentID } if err := mark.Validate(); err != nil { return domain.CustomVerification{}, false, err } now := s.nowLocked() key := customVerificationKeyOf(mark.VerifierBotID, mark.Peer) if id, found := s.marksByPeer[key]; found { current := s.marks[id] replaced := current.VerifierBotID != mark.VerifierBotID if replaced { if s.markCounts[mark.VerifierBotID] >= domain.MaxCustomVerificationsPerVerifier { return domain.CustomVerification{}, false, domain.ErrCustomVerificationLimit } if s.markCounts[current.VerifierBotID] <= 1 { delete(s.markCounts, current.VerifierBotID) } else { s.markCounts[current.VerifierBotID]-- } s.markCounts[mark.VerifierBotID]++ current.VerifierBotID = mark.VerifierBotID current.CreatedAt = now } current.IconDocumentID = mark.IconDocumentID current.Description = mark.Description current.GrantedByUserID = mark.GrantedByUserID current.UpdatedAt = laterVerificationTime(current.UpdatedAt, now) current.Version++ s.marks[id] = current return current, replaced, nil } if s.markCounts[mark.VerifierBotID] >= domain.MaxCustomVerificationsPerVerifier { return domain.CustomVerification{}, false, domain.ErrCustomVerificationLimit } stored := mark stored.ID = s.nextMarkID stored.CreatedAt = now stored.UpdatedAt = now stored.Version = 1 s.nextMarkID++ s.marks[stored.ID] = stored s.marksByPeer[key] = stored.ID s.markCounts[stored.VerifierBotID]++ return stored, true, nil } func (s *BotVerificationStore) revokeLocked(verifierBotID int64, peer domain.Peer) bool { key := customVerificationKeyOf(verifierBotID, peer) id, found := s.marksByPeer[key] if !found { return false } if s.marks[id].VerifierBotID != verifierBotID { return false } delete(s.marks, id) delete(s.marksByPeer, key) if s.markCounts[verifierBotID] <= 1 { delete(s.markCounts, verifierBotID) } else { s.markCounts[verifierBotID]-- } return true } // projectedMarkLocked returns the peer's single mark when its verifier is enabled. func (s *BotVerificationStore) projectedMarkLocked(peer domain.Peer) (domain.CustomVerification, bool) { var best domain.CustomVerification found := false for _, mark := range s.marks { if mark.Peer != peer { continue } if settings, ok := s.verifiers[mark.VerifierBotID]; !ok || !settings.Enabled { continue } if !found || mark.ID > best.ID { best = mark found = true } } return best, found } func (s *BotVerificationStore) pendingRequestLocked(verifierBotID int64, peer domain.Peer) (domain.CustomVerificationRequest, bool) { for _, id := range sortedBotVerificationIDs(s.requests, true) { req := s.requests[id] if req.VerifierBotID == verifierBotID && req.Peer == peer && req.Status == domain.CustomVerificationPending { return req, true } } return domain.CustomVerificationRequest{}, false } // botVerificationSnapshot is the whole store, shallow-copied. Every stored value // is a flat struct, so a map copy is a deep copy and restoring it undoes both the // decision and whatever its callback wrote. type botVerificationSnapshot struct { nextIconID int64 nextMarkID int64 nextRequestID int64 icons map[int64]domain.VerificationIcon iconsByDocument map[int64]int64 verifiers map[int64]domain.BotVerifierSettings marks map[int64]domain.CustomVerification marksByPeer map[customVerificationKey]int64 markCounts map[int64]int requests map[int64]domain.CustomVerificationRequest } func (s *BotVerificationStore) snapshotLocked() botVerificationSnapshot { return botVerificationSnapshot{ nextIconID: s.nextIconID, nextMarkID: s.nextMarkID, nextRequestID: s.nextRequestID, icons: copyBotVerificationMap(s.icons), iconsByDocument: copyBotVerificationMap(s.iconsByDocument), verifiers: copyBotVerificationMap(s.verifiers), marks: copyBotVerificationMap(s.marks), marksByPeer: copyBotVerificationMap(s.marksByPeer), markCounts: copyBotVerificationMap(s.markCounts), requests: copyBotVerificationMap(s.requests), } } func (s *BotVerificationStore) restoreLocked(snapshot botVerificationSnapshot) { s.nextIconID = snapshot.nextIconID s.nextMarkID = snapshot.nextMarkID s.nextRequestID = snapshot.nextRequestID s.icons = snapshot.icons s.iconsByDocument = snapshot.iconsByDocument s.verifiers = snapshot.verifiers s.marks = snapshot.marks s.marksByPeer = snapshot.marksByPeer s.markCounts = snapshot.markCounts s.requests = snapshot.requests } // nowLocked hands out strictly increasing timestamps at timestamptz resolution. func (s *BotVerificationStore) nowLocked() time.Time { now := time.Now().UTC().Truncate(time.Microsecond) if !now.After(s.lastNow) { now = s.lastNow.Add(time.Microsecond) } s.lastNow = now return now } // ---- helpers ---------------------------------------------------------------- func customVerificationKeyOf(_ int64, peer domain.Peer) customVerificationKey { return customVerificationKey{ verifierBotID: 0, peerType: peer.Type, peerID: peer.ID, } } // customVerificationDecisionNeedsApply reports whether a decision moves the mark // and therefore needs the callback: approving grants it, revoking removes it, // and a rejection never had one to move. func customVerificationDecisionNeedsApply(status domain.CustomVerificationRequestStatus) bool { return status == domain.CustomVerificationApproved || status == domain.CustomVerificationRevoked } // customVerificationDecisionState projects a decision onto the stored row. // // The approved/rejected stamps are not free-form: 0155 pairs each with its // status, so a revocation has to clear ApprovedAt as it leaves the approved // state. The application's history of having been approved lives in the status // itself -- revoked is reachable only from approved. func customVerificationDecisionState(current domain.CustomVerificationRequest, status domain.CustomVerificationRequestStatus, decidedBy, reason, note string, now time.Time) domain.CustomVerificationRequest { next := current next.Status = status next.DecidedBy = decidedBy next.DecisionReason = reason next.InternalNote = note next.ApprovedAt = time.Time{} next.RejectedAt = time.Time{} switch status { case domain.CustomVerificationApproved: next.ApprovedAt = now case domain.CustomVerificationRejected: next.RejectedAt = now } next.Version = current.Version + 1 next.UpdatedAt = laterVerificationTime(next.UpdatedAt, now) return next } func normalizeCustomVerificationRequest(req domain.CustomVerificationRequest) domain.CustomVerificationRequest { req.PeerTitle = strings.TrimSpace(req.PeerTitle) req.PeerUsername = domain.NormalizeUsername(req.PeerUsername) req.Reason = strings.TrimSpace(req.Reason) req.RequestedDescription = strings.TrimSpace(req.RequestedDescription) req.DecidedBy = strings.TrimSpace(req.DecidedBy) req.DecisionReason = strings.TrimSpace(req.DecisionReason) req.InternalNote = strings.TrimSpace(req.InternalNote) req.CorrelationID = strings.TrimSpace(req.CorrelationID) return req } // botVerifierSettingsColumnsFit reports whether the verifier text fits the // columns in bytes, which the rune-counting domain Validate cannot answer. func botVerifierSettingsColumnsFit(settings domain.BotVerifierSettings) bool { return len(settings.CompanyName) <= maxVerifierCompanyBytes && len(settings.DefaultDescription) <= maxVerifierDescriptionBytes && len(settings.GrantReason) <= maxVerifierGrantReasonBytes } // validateCustomVerificationRequestColumns guards the octet_length CHECKs on // custom_verification_requests, so both backends reject an over-long snapshot // the same way. func validateCustomVerificationRequestColumns(req domain.CustomVerificationRequest) error { if len(req.PeerTitle) > maxCustomVerificationTitleBytes || len(req.PeerUsername) > maxCustomVerificationUsernameBytes || len(req.Reason) > maxCustomVerificationReasonBytes || len(req.RequestedDescription) > maxCustomVerificationInputBytes || len(req.DecidedBy) > maxCustomVerificationDecidedByBytes || len(req.DecisionReason) > maxCustomVerificationDecisionBytes || len(req.InternalNote) > maxCustomVerificationNoteBytes || len(req.CorrelationID) > maxCustomVerificationCorrelationBytes { return domain.ErrCustomVerificationRequestInvalid } return nil } func containsCustomVerificationStatus(statuses []domain.CustomVerificationRequestStatus, status domain.CustomVerificationRequestStatus) bool { for _, item := range statuses { if item == status { return true } } return false } // sortedBotVerificationIDs orders the keys of a store map: ascending for the // lookups that mirror a LIMIT 1 scan, descending for the listings that page // newest first. func sortedBotVerificationIDs[V any](items map[int64]V, ascending bool) []int64 { ids := make([]int64, 0, len(items)) for id := range items { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { if ascending { return ids[i] < ids[j] } return ids[i] > ids[j] }) return ids } func copyBotVerificationMap[K comparable, V any](items map[K]V) map[K]V { out := make(map[K]V, len(items)) for key, value := range items { out[key] = value } return out } // validBotVerificationPeer mirrors the peer_type CHECK: only users and channels // carry a third-party mark. func validBotVerificationPeer(peer domain.Peer) bool { return botVerificationPeerType(peer.Type) && peer.ID > 0 } func botVerificationPeerType(peerType domain.PeerType) bool { return peerType == domain.PeerTypeUser || peerType == domain.PeerTypeChannel } func botVerificationLimit(limit int) int { if limit <= 0 { return defaultBotVerificationListLimit } if limit > maxBotVerificationListLimit { return maxBotVerificationListLimit } return limit } // parseBotVerificationQuery splits an admin search term into its two shapes: a // number addresses a row id or a peer id, anything else is text. Telegram // usernames never start with a digit, so the two shapes cannot collide. func parseBotVerificationQuery(query string) (numeric int64, isNumeric bool, text string) { query = strings.TrimSpace(query) query = strings.TrimPrefix(query, "@") query = strings.TrimSpace(query) if query == "" { return 0, false, "" } if id, err := strconv.ParseInt(query, 10, 64); err == nil && id > 0 { return id, true, "" } return 0, false, strings.ToLower(query) }