owpengram-server/internal/app/botverification/service.go
Egor Egorov fff8de783a
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>
2026-07-28 01:18:00 +08:00

1527 lines
58 KiB
Go

// Package botverification implements the third-party bot verification use cases
// (core.telegram.org/api/bots/verification): the operator-curated icon catalogue,
// the verifier status a bot is granted, the marks a verifier applies to peers,
// and the application queue in front of them.
//
// This is the third-party badge (botVerification#f93cd45c projected onto
// user.bot_verification_icon / channel.bot_verification_icon /
// userFull.bot_verification / channelFull.bot_verification /
// chatInvite.bot_verification, with botInfo.verifier_settings advertising the
// verifier itself), never the platform checkmark. The two mechanisms never read
// or write each other's state: only the operator grants the platform flag (see
// app/verification), and a third-party verifier must never be able to mint one.
//
// Two invariants shape everything here:
//
// - A mark is only worth as much as the verifier behind it. Verifier status is
// granted per deployment rather than earned per peer, so every mutation
// re-derives "this bot may verify right now" from stored settings instead of
// trusting the caller, and the operator kill switch (BotVerifierSettings.Enabled)
// is honoured on every write path.
// - An icon that names no fetchable custom emoji document is worse than no
// icon at all: clients resolve it through messages.getCustomEmojiDocuments, so
// an unresolvable id renders as *nothing*. The peer then looks unverified while
// the server insists it is marked, and nobody can tell the difference from the
// outside. That is why the catalogue and every verifier grant are validated
// against real documents through IconResolver, always before the write.
//
// The package owns no protocol or storage detail: peers are resolved through
// narrow ports the process wires to the existing app services, every durable
// mutation goes through store.BotVerificationStore, and the two decision paths
// (approve / revoke) move the mark inside the store's own transaction so an
// approved application can never exist without its mark.
package botverification
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
const (
// defaultListLimit / maxListLimit bound one admin listing page. They match the
// store's own clamps, so the service never asks for a page the backend would
// silently shrink.
defaultListLimit = 50
maxListLimit = 200
// defaultApplicantLimit / maxApplicantLimit bound the applicant's own history
// as rendered by a verifier bot's /status command.
defaultApplicantLimit = 20
maxApplicantLimit = 100
// defaultRequestRateLimit / defaultRequestRateWindow match the shipped
// TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT / _WINDOW defaults.
defaultRequestRateLimit = 5
defaultRequestRateWindow = 24 * time.Hour
// requestRateLimitKeyPrefix namespaces the per-applicant creation budget in the
// shared window limiter, so it cannot collide with the official verification
// budget (verification:apply:) counted for the same user id.
requestRateLimitKeyPrefix = "botverification:request:"
// maxPeerTitleRunes bounds the stored title snapshot. 256 runes cannot exceed
// the 1024-byte column CHECK even in the worst-case 4-bytes-per-rune encoding.
maxPeerTitleRunes = 256
)
// ErrDisabled reports that third-party verification is switched off for this
// deployment. Every mutation refuses explicitly rather than pretending to
// succeed: an operator must never believe a mark was applied when it was not.
//
// Reads are deliberately not gated by the flag. The badges already granted keep
// rendering, exactly like a platform badge survives
// TELESRV_VERIFICATION_ENABLED=false, and blanking a specific verifier's marks is
// what the per-verifier kill switch is for. A deployment that wants the
// pre-feature wire shape leaves rpc.Deps.BotVerifications nil instead.
var ErrDisabled = errors.New("third-party bot verification is disabled")
// Store is the durable third-party verification boundary. It is an alias of
// store.BotVerificationStore rather than a copy, so the process-wide store
// satisfies it with no adapter and the two can never drift apart.
type Store = store.BotVerificationStore
// UserDirectory resolves viewer-independent account facts: whether a peer exists,
// whether it is a bot, and the title/username snapshot recorded on an
// application. users.Service satisfies it directly -- AdminUser is the existing
// "administrative truth about an account" reader, with no viewer projection and
// no privacy filtering, which is exactly what a verification check needs.
type UserDirectory interface {
AdminUser(ctx context.Context, userID int64) (domain.User, bool, error)
}
// BotDirectory resolves bot ownership. bots.Service satisfies it directly.
//
// It answers two different questions with the same call: "is this caller allowed
// to act as this verifier bot?" on the RPC path, and "does this applicant control
// the bot it is filing for?" on the application path.
type BotDirectory interface {
OwnsBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error)
}
// ChannelDirectory resolves channel facts and the applicant's rights on a
// channel. channels.Service satisfies it directly.
//
// GetParticipant is the channel aggregate's own answer about a membership, so
// this package never re-derives admin rights from a members table and cannot
// drift from the aggregate. Unlike the official verification flow this does not
// go through ListAdminedPublicChannels: a third-party mark is meaningful on a
// private channel too, and restricting applications to public peers would be a
// policy this feature does not have.
type ChannelDirectory interface {
GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error)
GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error)
}
// PeerDirectory is the whole peer-resolution surface this service needs. It is
// the union of the three narrow ports so a process holding one aggregate facade
// can inject it once; the individual options exist because the shipped process
// has three separate services.
type PeerDirectory interface {
UserDirectory
BotDirectory
ChannelDirectory
}
// PeerNotifier is the protocol edge hook invoked after a mark has already
// committed: it drops the cached peer projections and pushes the change to online
// clients. rpc.Router implements it (NotifyPeerBotVerification).
//
// A push failure never invalidates a committed mark, so it is logged rather than
// returned: retrying the mutation would be wrong, and the next authoritative peer
// read repairs the projection anyway.
type PeerNotifier interface {
NotifyPeerBotVerification(ctx context.Context, peer domain.Peer) error
}
// IconResolver resolves custom emoji documents by id. files.Service satisfies it
// directly: GetDocuments is the same reader messages.getCustomEmojiDocuments
// answers from, which is the whole point -- the icon is validated against exactly
// the documents a client would be able to fetch.
type IconResolver interface {
GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error)
}
// MarkApplier writes the mark from inside the store's decision transaction.
//
// store.BotVerificationStore satisfies it, and that is the default. It exists as
// its own port because of how the decision transaction works: the store hands the
// apply callback a context carrying its transaction, and the mark has to be
// written through *that* handle or "approved implies the mark exists" quietly
// degrades into two independent commits — a rolled back decision would leave the
// badge behind. Reaching a transaction handle is a storage detail the app layer
// must not know, so the process injects an implementation that pulls it out of the
// context, exactly like verification.PeerVerifier does for the platform flag.
//
// A backend whose decision is a single statement, and the in-memory backend (which
// rolls its own snapshot back), need no adapter at all: the plain store is correct
// there, which is why this port is optional.
type MarkApplier interface {
GrantCustomVerification(ctx context.Context, mark domain.CustomVerification) (domain.CustomVerification, bool, error)
RevokeCustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
}
// ApplicantNotifier delivers a decision to the applicant as a message from the
// verifier bot. The bots service implements it.
//
// Delivery is best effort and happens after the decision has committed: an
// applicant who blocked the verifier bot must not be able to roll back a landed
// decision.
type ApplicantNotifier interface {
SendVerificationDecision(ctx context.Context, recipientUserID int64, req domain.CustomVerificationRequest) error
}
// RateLimiter is the windowed limiter bounding application creation. It is an
// alias of store.RateLimiter rather than a copy, so the process-wide limiter
// satisfies it with no adapter.
type RateLimiter = store.RateLimiter
// Service is the third-party bot verification use-case layer.
type Service struct {
store Store
users UserDirectory
bots BotDirectory
channels ChannelDirectory
peers PeerNotifier
documents IconResolver
applicant ApplicantNotifier
marks MarkApplier
limiter RateLimiter
requestLimit int
requestWindow time.Duration
enabled bool
maxPerVerifier int
now func() time.Time
log *zap.Logger
}
// Option adjusts optional service dependencies.
type Option func(*Service)
// WithStore injects the third-party verification store.
func WithStore(st Store) Option {
return func(s *Service) { s.store = st }
}
// WithPeerDirectory injects one facade covering user, bot and channel resolution.
func WithPeerDirectory(dir PeerDirectory) Option {
return func(s *Service) {
if dir == nil {
return
}
s.users = dir
s.bots = dir
s.channels = dir
}
}
// WithUserDirectory injects the account reader (users.Service).
func WithUserDirectory(dir UserDirectory) Option {
return func(s *Service) {
if dir != nil {
s.users = dir
}
}
}
// WithBotDirectory injects the bot ownership reader (bots.Service).
func WithBotDirectory(dir BotDirectory) Option {
return func(s *Service) {
if dir != nil {
s.bots = dir
}
}
}
// WithChannelDirectory injects the channel reader (channels.Service).
func WithChannelDirectory(dir ChannelDirectory) Option {
return func(s *Service) {
if dir != nil {
s.channels = dir
}
}
}
// WithPeerNotifier injects the projection-invalidation/push hook.
func WithPeerNotifier(notifier PeerNotifier) Option {
return func(s *Service) {
if notifier != nil {
s.peers = notifier
}
}
}
// WithIconResolver injects the custom emoji document reader (files.Service).
func WithIconResolver(resolver IconResolver) Option {
return func(s *Service) {
if resolver != nil {
s.documents = resolver
}
}
}
// WithMarkApplier injects the transaction-aware mark writer used by the decision
// callbacks. Without it the plain store is used; see MarkApplier for when that is
// not enough.
func WithMarkApplier(applier MarkApplier) Option {
return func(s *Service) {
if applier != nil {
s.marks = applier
}
}
}
// WithApplicantNotifier injects the verifier-bot delivery port.
func WithApplicantNotifier(notifier ApplicantNotifier) Option {
return func(s *Service) {
if notifier != nil {
s.applicant = notifier
}
}
}
// SetPeerNotifier installs the protocol-edge hook after construction. The router
// is built after the services it serves, so the push hook cannot be an option in
// every process; this mirrors verification.Service.SetPeerNotifier.
func (s *Service) SetPeerNotifier(notifier PeerNotifier) {
if s == nil || notifier == nil {
return
}
s.peers = notifier
}
// SetApplicantNotifier installs the verifier-bot delivery port after
// construction, for processes where the bots service is wired to this service in
// turn and cannot be passed as an option.
func (s *Service) SetApplicantNotifier(notifier ApplicantNotifier) {
if s == nil || notifier == nil {
return
}
s.applicant = notifier
}
// WithRateLimiter installs the application-creation budget. A non-positive limit
// or window disables the check, matching how the RPC edge treats its own limits.
func WithRateLimiter(limiter RateLimiter, limit int, window time.Duration) Option {
return func(s *Service) {
s.limiter = limiter
s.requestLimit = limit
s.requestWindow = window
}
}
// WithEnabled toggles the feature's mutations.
func WithEnabled(enabled bool) Option {
return func(s *Service) { s.enabled = enabled }
}
// WithMaxPerVerifier bounds how many peers one verifier may mark. Zero disables
// the bound, which only defers to the store's own
// domain.MaxCustomVerificationsPerVerifier: verifier status is granted per
// deployment, not earned per peer, so an unbounded verifier would be an unbounded
// badge printer.
func WithMaxPerVerifier(limit int) Option {
return func(s *Service) {
if limit >= 0 {
s.maxPerVerifier = limit
}
}
}
// WithClock injects the clock (tests).
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// WithLogger injects the service logger.
func WithLogger(log *zap.Logger) Option {
return func(s *Service) {
if log != nil {
s.log = log
}
}
}
// NewService creates the third-party verification service. It is enabled by
// default so the only switch is the configuration flag, and it is safe without
// dependencies: every mutation reports a configuration error instead of
// proceeding with a half-wired security check, and every projection read answers
// "no mark" instead of failing the response it is embedded in.
func NewService(opts ...Option) *Service {
s := &Service{
requestLimit: defaultRequestRateLimit,
requestWindow: defaultRequestRateWindow,
enabled: true,
maxPerVerifier: domain.MaxCustomVerificationsPerVerifier,
now: time.Now,
log: zap.NewNop(),
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
if s.now == nil {
s.now = time.Now
}
if s.log == nil {
s.log = zap.NewNop()
}
if s.requestLimit < 0 {
s.requestLimit = 0
}
if s.requestWindow < 0 {
s.requestWindow = 0
}
if s.maxPerVerifier < 0 {
s.maxPerVerifier = 0
}
return s
}
// Enabled reports whether the feature's mutations are switched on.
func (s *Service) Enabled() bool { return s != nil && s.enabled }
// Ready reports whether mutations are on and backed by a store.
func (s *Service) Ready() bool { return s.Enabled() && s.store != nil }
// writeStore is the gate every mutation passes: the feature flag first, then the
// store. A missing store is a wiring mistake and says so, rather than surfacing
// as a nil dereference on the first grant.
func (s *Service) writeStore() (Store, error) {
if s == nil || !s.enabled {
return nil, ErrDisabled
}
if s.store == nil {
return nil, fmt.Errorf("bot verification store is not configured")
}
return s.store, nil
}
// markApplier resolves the writer the decision callbacks use: the injected
// transaction-aware one when the process supplied it, otherwise the store itself.
func (s *Service) markApplier(st Store) MarkApplier {
if s != nil && s.marks != nil {
return s.marks
}
return st
}
// readStore is the gate for the operator-facing reads. Unlike writeStore it
// ignores the feature flag: an admin panel must still be able to show what was
// granted while the feature is switched off, which is also how it can be audited
// after a shutdown.
func (s *Service) readStore() (Store, error) {
if s == nil || s.store == nil {
return nil, fmt.Errorf("bot verification store is not configured")
}
return s.store, nil
}
// ---------------------------------------------------------------------------
// Projection reads (hot serialisation path)
// ---------------------------------------------------------------------------
// PeerVerification returns the peer's single wire-visible mark.
//
// This runs on every peer serialisation, so an unwired service answers
// domain.ErrCustomVerificationNotFound rather than a configuration error: the RPC
// edge treats both the same way (no badge), and "not found" is the truthful answer
// when nothing can be stored at all.
func (s *Service) PeerVerification(ctx context.Context, peer domain.Peer) (domain.CustomVerification, error) {
if s == nil || s.store == nil {
return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound
}
return s.store.PeerVerification(ctx, peer)
}
// PeerVerificationBatch is the N+1-free variant used by the response-boundary
// overlay: peers without a mark are simply absent from the map.
func (s *Service) PeerVerificationBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error) {
if s == nil || s.store == nil || len(peers) == 0 {
return map[domain.Peer]domain.CustomVerification{}, nil
}
return s.store.PeerVerificationBatch(ctx, peers)
}
// VerifierSettings reads one bot's verifier status, enabled or not: the caller
// needs the disabled row too, to render the kill switch and to explain
// BOT_VERIFIER_FORBIDDEN. The projection edge is what refuses to advertise a
// disabled verifier in botInfo.
func (s *Service) VerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error) {
if s == nil || s.store == nil {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
return s.store.BotVerifierSettings(ctx, botID)
}
// VerifierSettingsBatch resolves verifier status for several bots at once for the
// botInfo projection; bots without verifier status are absent from the map.
func (s *Service) VerifierSettingsBatch(ctx context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error) {
if s == nil || s.store == nil || len(botIDs) == 0 {
return map[int64]domain.BotVerifierSettings{}, nil
}
return s.store.BotVerifierSettingsBatch(ctx, botIDs)
}
// ---------------------------------------------------------------------------
// bots.setCustomVerification
// ---------------------------------------------------------------------------
// SetCustomVerification applies bots.setCustomVerification: a verifier bot adding
// or removing its own mark on a peer.
//
// Every check the RPC edge already made is made again here, in this order:
//
// 1. the bot has a live (enabled) verifier row -- domain.ErrVerifierForbidden
// covers "never was a verifier" and "the operator switched it off" alike,
// because a client may not tell the two apart;
// 2. the caller is the bot itself or its owner. The edge resolved this from the
// TL constructor, but an edge is not a permission boundary: this service is
// also driven from the bot dialog and the admin panel, and a check that only
// exists at one caller is a check that will eventually be bypassed;
// 3. the target peer exists and is verifiable at all.
//
// The description is resolved through domain.BotVerifierSettings.DescriptionFor,
// which is the single place the "may this verifier write its own text" rule lives,
// and the icon always comes from the verifier's *settings* -- a verifier cannot
// pick an icon per peer, so it cannot smuggle in one the operator never approved.
//
// changed reports whether anything actually moved: re-applying an identical mark
// or revoking an absent one answers false with no error, which is what keeps a
// retrying client from seeing a spurious failure. The peer push only runs when
// something moved, and its failure is logged rather than returned: the data is
// already consistent and re-running the mutation would be wrong.
func (s *Service) SetCustomVerification(ctx context.Context, req domain.SetCustomVerificationRequest) (bool, error) {
st, err := s.writeStore()
if err != nil {
return false, err
}
if err := req.Validate(); err != nil {
return false, err
}
settings, err := s.enabledVerifier(ctx, st, req.VerifierBotID)
if err != nil {
return false, err
}
if err := s.checkVerifierCaller(ctx, req.VerifierBotID, req.CallerUserID); err != nil {
return false, err
}
if !req.Enabled {
// Only the peer's shape is checked on the way out (Validate did that): a
// verifier must still be able to strip its mark from a peer that has since
// been deleted or become unresolvable, which is exactly when stripping it
// matters most.
removed, err := st.RevokeCustomVerification(ctx, req.VerifierBotID, req.Peer)
if err != nil {
return false, err
}
if removed {
s.notifyPeer(ctx, req.Peer, "revoke")
}
return removed, nil
}
if _, err := s.resolvePeer(ctx, req.Peer); err != nil {
return false, err
}
description, err := settings.DescriptionFor(req.CustomDescription)
if err != nil {
return false, err
}
// The stored mark is compared before writing, so an idempotent re-apply is
// reported as changed=false instead of burning a version and pushing an update
// nobody can observe. The per-verifier bound is only spent on a *new* mark: an
// existing one must stay re-describable even at the limit.
existing, exists, err := s.markState(ctx, st, req.VerifierBotID, req.Peer)
if err != nil {
return false, err
}
if exists {
if existing.IconDocumentID == settings.IconDocumentID && existing.Description == description {
return false, nil
}
} else if err := s.checkVerifierQuota(ctx, st, req.VerifierBotID); err != nil {
return false, err
}
if _, _, err := st.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: req.VerifierBotID,
Peer: req.Peer,
IconDocumentID: settings.IconDocumentID,
Description: description,
GrantedByUserID: req.CallerUserID,
}); err != nil {
return false, err
}
s.notifyPeer(ctx, req.Peer, "grant")
return true, nil
}
// ---------------------------------------------------------------------------
// Icon catalogue
// ---------------------------------------------------------------------------
// UpsertIcon adds or updates a catalogue entry, keyed by custom emoji document id.
//
// The document is resolved before anything is written. An entry pointing at a
// document no client can fetch is the one failure mode of this feature that is
// invisible from the outside: the badge simply does not render, and the operator
// sees a marked peer that looks unmarked everywhere else.
func (s *Service) UpsertIcon(ctx context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error) {
st, err := s.writeStore()
if err != nil {
return domain.VerificationIcon{}, err
}
icon.Name = strings.TrimSpace(icon.Name)
if err := icon.Validate(); err != nil {
return domain.VerificationIcon{}, err
}
if err := s.checkIconDocument(ctx, icon.DocumentID); err != nil {
return domain.VerificationIcon{}, err
}
return st.UpsertVerificationIcon(ctx, icon)
}
// SetIconActive retires or restores a catalogue entry. Marks already granted with
// it keep rendering -- the icon id is denormalised onto the mark -- so retiring an
// entry stops new grants without silently blanking existing badges.
func (s *Service) SetIconActive(ctx context.Context, iconID int64, active bool) (domain.VerificationIcon, error) {
st, err := s.writeStore()
if err != nil {
return domain.VerificationIcon{}, err
}
if iconID <= 0 {
return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound
}
return st.SetVerificationIconActive(ctx, iconID, active)
}
// Icons lists the catalogue, newest first.
func (s *Service) Icons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error) {
st, err := s.readStore()
if err != nil {
return nil, err
}
return st.ListVerificationIcons(ctx, activeOnly, clampLimit(limit, defaultListLimit, maxListLimit))
}
// ---------------------------------------------------------------------------
// Verifier status (operator actions)
// ---------------------------------------------------------------------------
// GrantVerifier grants or updates a bot's verifier status.
//
// Everything is validated before the write, because a verifier row is the
// authority every later mark derives from:
//
// - the subject is a real bot account and not a built-in system entity. A
// system bot carries its identity by construction, and making one a
// third-party verifier would let this feature reach into the platform's own
// accounts;
// - the icon exists in the operator's catalogue, is active, and is usable by
// this bot (a reserved entry belongs to one verifier only);
// - the icon's custom emoji document really exists, so the badge renders.
//
// The push afterwards is for the *bot itself*: its botInfo.verifier_settings just
// changed. The peers it has marked are deliberately not fanned out -- a verifier
// may hold up to domain.MaxCustomVerificationsPerVerifier marks, and turning one
// operator action into ten thousand pushes would be a self-inflicted stampede.
// Their projections converge on the next authoritative read (getUsers /
// getFullUser / getDifference), which is the same convergence the peer read-model
// version already guarantees.
func (s *Service) GrantVerifier(ctx context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error) {
st, err := s.writeStore()
if err != nil {
return domain.BotVerifierSettings{}, err
}
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 err := s.checkVerifierBot(ctx, settings.BotID); err != nil {
return domain.BotVerifierSettings{}, err
}
if err := s.checkCatalogueIcon(ctx, st, settings.BotID, settings.IconDocumentID); err != nil {
return domain.BotVerifierSettings{}, err
}
stored, err := st.UpsertBotVerifierSettings(ctx, settings)
if err != nil {
return domain.BotVerifierSettings{}, err
}
s.notifyPeer(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: stored.BotID}, "grant_verifier")
return stored, nil
}
// SetVerifierEnabled flips the operator kill switch. The verifier keeps its row
// and the marks it granted, but it can grant nothing new and stops advertising
// itself, so flipping the switch back restores exactly what was there.
//
// A no-op flip is detected before the write, so it neither burns a version nor
// pushes an update: an admin panel toggling to the current value must not look
// like a change in the audit trail.
func (s *Service) SetVerifierEnabled(ctx context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error) {
st, err := s.writeStore()
if err != nil {
return domain.BotVerifierSettings{}, err
}
if botID <= 0 {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
current, err := st.BotVerifierSettings(ctx, botID)
if err != nil {
return domain.BotVerifierSettings{}, err
}
if current.Enabled == enabled {
return current, nil
}
stored, err := st.SetBotVerifierEnabled(ctx, botID, enabled)
if err != nil {
return domain.BotVerifierSettings{}, err
}
// Only the verifier's own botInfo is pushed; see GrantVerifier for why its
// marked peers are left to converge on their next authoritative read.
s.notifyPeer(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: botID}, "set_verifier_enabled")
return stored, nil
}
// RevokeVerifier removes verifier status entirely. Its marks cascade away with it
// in the store, because a mark whose verifier no longer exists has nothing to
// render; the applications survive as history, since they reference users.
func (s *Service) RevokeVerifier(ctx context.Context, botID int64) (bool, error) {
st, err := s.writeStore()
if err != nil {
return false, err
}
if botID <= 0 {
return false, domain.ErrVerifierNotFound
}
removed, err := st.DeleteBotVerifierSettings(ctx, botID)
if err != nil {
return false, err
}
if removed {
s.notifyPeer(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: botID}, "revoke_verifier")
}
return removed, nil
}
// Verifiers lists verifier bots for the admin panel.
func (s *Service) Verifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
st, err := s.readStore()
if err != nil {
return nil, err
}
return st.ListBotVerifiers(ctx, enabledOnly, clampLimit(limit, defaultListLimit, maxListLimit))
}
// ---------------------------------------------------------------------------
// Granted marks
// ---------------------------------------------------------------------------
// Marks is the admin listing query over granted marks, with keyset paging.
func (s *Service) Marks(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
st, err := s.readStore()
if err != nil {
return nil, err
}
if filter.PeerType != "" && !markablePeerType(filter.PeerType) {
return nil, domain.ErrCustomVerificationTargetInvalid
}
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
return st.ListCustomVerifications(ctx, filter)
}
// RevokeMark removes one verifier's mark from a peer on the operator's behalf.
// A repeated revoke reports changed=false rather than an error, so a panel retry
// is harmless.
func (s *Service) RevokeMark(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
st, err := s.writeStore()
if err != nil {
return false, err
}
if verifierBotID <= 0 {
return false, domain.ErrVerifierNotFound
}
// The peer is checked for shape only: an operator must still be able to strip a
// mark from a peer that has since been deleted or become unresolvable, which is
// exactly when stripping it matters most.
if !markablePeer(peer) {
return false, domain.ErrCustomVerificationTargetInvalid
}
removed, err := st.RevokeCustomVerification(ctx, verifierBotID, peer)
if err != nil {
return false, err
}
if removed {
s.notifyPeer(ctx, peer, "revoke_mark")
}
return removed, nil
}
// ---------------------------------------------------------------------------
// Applications
// ---------------------------------------------------------------------------
// CreateRequest files an application with a verifier bot.
//
// The checks run in a fixed order, cheapest and most explanatory first, with the
// rate limit last so a refused application never costs the applicant budget and
// an attacker cannot exhaust a victim's window by probing:
//
// 1. the verifier exists and is enabled -- filing with a switched-off verifier
// would produce a queue nobody can decide;
// 2. the applicant controls the target (owns the bot, is the channel's creator or
// an administrator carrying change_info, or is the user target themselves);
// 3. this verifier has not already marked the peer, so the queue cannot be used
// to re-apply for a badge the peer already carries;
// 4. the requested description is one this verifier is allowed to apply, so an
// application cannot be filed in a shape that could never be approved;
// 5. the per-applicant creation budget.
//
// The stored title/username snapshot comes from the resolved peer, never from the
// request: an audit record whose subject a client could name freely would be
// worthless.
func (s *Service) CreateRequest(ctx context.Context, req domain.CustomVerificationRequest) (domain.CustomVerificationRequest, error) {
st, err := s.writeStore()
if err != nil {
return domain.CustomVerificationRequest{}, err
}
// A filed application is pending by definition and carries no decision; the
// store enforces the same thing, and forcing it here keeps a caller from
// smuggling a decided row past the queue.
req.Status = domain.CustomVerificationPending
req.DecidedBy = ""
req.DecisionReason = ""
req.ApprovedAt = time.Time{}
req.RejectedAt = time.Time{}
req.Reason = strings.TrimSpace(req.Reason)
req.RequestedDescription = strings.TrimSpace(req.RequestedDescription)
if err := req.Validate(); err != nil {
return domain.CustomVerificationRequest{}, err
}
settings, err := s.enabledVerifier(ctx, st, req.VerifierBotID)
if err != nil {
return domain.CustomVerificationRequest{}, err
}
snapshot, err := s.resolvePeer(ctx, req.Peer)
if err != nil {
return domain.CustomVerificationRequest{}, err
}
controls, err := s.controls(ctx, req.ApplicantUserID, snapshot.peer)
if err != nil {
return domain.CustomVerificationRequest{}, err
}
if !controls {
return domain.CustomVerificationRequest{}, domain.ErrVerificationNotOwner
}
// The (verifier, peer) slot is single-occupancy: the store refuses a second
// pending application, and an existing mark makes an application pointless.
// Both answer the same way, because from an applicant's side both mean "there is
// already a verification for this pair".
switch _, err := st.CustomVerification(ctx, req.VerifierBotID, snapshot.peer); {
case err == nil:
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestExists
case errors.Is(err, domain.ErrCustomVerificationNotFound):
default:
return domain.CustomVerificationRequest{}, err
}
// A description this verifier may not apply is refused now rather than at
// approval time, where it would leave an application nobody can decide.
if _, err := settings.DescriptionFor(req.RequestedDescription); err != nil {
return domain.CustomVerificationRequest{}, err
}
if err := s.checkRequestRate(ctx, req.ApplicantUserID); err != nil {
return domain.CustomVerificationRequest{}, err
}
req.Peer = snapshot.peer
req.PeerTitle = snapshot.title
req.PeerUsername = snapshot.username
return st.CreateCustomVerificationRequest(ctx, req)
}
// Request reads one application by id.
func (s *Service) Request(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
st, err := s.readStore()
if err != nil {
return domain.CustomVerificationRequest{}, err
}
if requestID <= 0 {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
}
return st.CustomVerificationRequest(ctx, requestID)
}
// Requests is the review-queue query with a bounded page size.
func (s *Service) Requests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
st, err := s.readStore()
if err != nil {
return nil, err
}
for _, status := range filter.Statuses {
if !status.Valid() {
return nil, domain.ErrCustomVerificationRequestInvalid
}
}
if filter.PeerType != "" && !markablePeerType(filter.PeerType) {
return nil, domain.ErrCustomVerificationTargetInvalid
}
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
return st.ListCustomVerificationRequests(ctx, filter)
}
// RequestCounts is the queue summary by status.
func (s *Service) RequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
st, err := s.readStore()
if err != nil {
return nil, err
}
return st.CustomVerificationRequestCounts(ctx)
}
// ApplicantRequests returns an applicant's own history, newest first, for a
// verifier bot's /status command.
func (s *Service) ApplicantRequests(ctx context.Context, applicantUserID int64, limit int) ([]domain.CustomVerificationRequest, error) {
st, err := s.readStore()
if err != nil {
return nil, err
}
if applicantUserID <= 0 {
return nil, domain.ErrCustomVerificationRequestInvalid
}
return st.CustomVerificationRequestsForApplicant(ctx, applicantUserID, clampLimit(limit, defaultApplicantLimit, maxApplicantLimit))
}
// PendingRequest returns the live application for a (verifier, peer) pair, if any.
func (s *Service) PendingRequest(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerificationRequest, error) {
st, err := s.readStore()
if err != nil {
return domain.CustomVerificationRequest{}, err
}
if verifierBotID <= 0 || !markablePeer(peer) {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
}
return st.PendingCustomVerificationRequest(ctx, verifierBotID, peer)
}
// Approve grants the mark an application asked for.
//
// The mark is applied by the store's own decision transaction through the apply
// callback, so "approved" and "the peer carries the mark" commit together or not
// at all -- an approved application without its mark is not a reachable state. The
// verifier's live status and the target's existence are re-checked here, against a
// freshly loaded snapshot: an application can sit in the queue for days, and a
// verifier switched off in the meantime must not be able to grant through the
// review path what the RPC path would refuse.
//
// A retried approval of an already-approved application is an idempotent no-op:
// no second grant, and no second notification to a peer's audience or to the
// applicant.
func (s *Service) Approve(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error) {
st, err := s.writeStore()
if err != nil {
return domain.CustomVerificationRequest{}, false, err
}
current, err := s.decidableRequest(ctx, st, requestID, version, domain.CustomVerificationApproved)
if err != nil {
return domain.CustomVerificationRequest{}, false, err
}
if current.Status == domain.CustomVerificationApproved {
return current, false, nil
}
settings, err := s.enabledVerifier(ctx, st, current.VerifierBotID)
if err != nil {
return domain.CustomVerificationRequest{}, false, err
}
if _, err := s.resolvePeer(ctx, current.Peer); err != nil {
return domain.CustomVerificationRequest{}, false, err
}
// As on the RPC path, the bound is only spent when the approval would create a
// mark rather than update one.
if _, exists, err := s.markState(ctx, st, current.VerifierBotID, current.Peer); err != nil {
return domain.CustomVerificationRequest{}, false, err
} else if !exists {
if err := s.checkVerifierQuota(ctx, st, current.VerifierBotID); err != nil {
return domain.CustomVerificationRequest{}, false, err
}
}
description := s.approvedDescription(settings, current.RequestedDescription)
applier := s.markApplier(st)
stored, changed, err := st.DecideCustomVerificationRequest(ctx, requestID, version, domain.CustomVerificationApproved, decidedBy, reason, note,
// The callback's ctx carries the decision's transaction, so the grant is
// written through the applier rather than the pooled store: the mark and the
// approval have to land or fail together.
func(ctx context.Context, decided domain.CustomVerificationRequest) error {
_, _, err := applier.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: decided.VerifierBotID,
Peer: decided.Peer,
IconDocumentID: settings.IconDocumentID,
Description: description,
// The grant is attributed to the verifier bot: the decision was made
// through its queue, not by the applicant who filed it.
GrantedByUserID: decided.VerifierBotID,
})
return err
})
if err != nil {
return domain.CustomVerificationRequest{}, false, err
}
if changed {
s.notifyPeer(ctx, stored.Peer, "approve")
s.notifyApplicant(ctx, stored)
}
return stored, changed, nil
}
// Reject closes an application against the applicant. A reason is mandatory: the
// audit trail must never contain a decision nobody can explain, and the applicant
// is told what it was. No peer state changes, so only the applicant is notified.
func (s *Service) Reject(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error) {
st, err := s.writeStore()
if err != nil {
return domain.CustomVerificationRequest{}, false, err
}
if strings.TrimSpace(reason) == "" {
return domain.CustomVerificationRequest{}, false, domain.ErrVerificationReasonRequired
}
current, err := s.decidableRequest(ctx, st, requestID, version, domain.CustomVerificationRejected)
if err != nil {
return domain.CustomVerificationRequest{}, false, err
}
if current.Status == domain.CustomVerificationRejected {
return current, false, nil
}
// No apply callback: a rejection never had a mark to move.
stored, changed, err := st.DecideCustomVerificationRequest(ctx, requestID, version, domain.CustomVerificationRejected, decidedBy, reason, note, nil)
if err != nil {
return domain.CustomVerificationRequest{}, false, err
}
if changed {
s.notifyApplicant(ctx, stored)
}
return stored, changed, nil
}
// RevokeRequest withdraws a granted mark through the application it came from.
// The mark is removed inside the decision transaction, on the same discipline as
// the approval that granted it, and the application stays as history: revoked is
// reachable only from approved, so it keeps meaning "was verified once".
//
// A reason is mandatory for the same reason it is on rejection.
func (s *Service) RevokeRequest(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error) {
st, err := s.writeStore()
if err != nil {
return domain.CustomVerificationRequest{}, false, err
}
if strings.TrimSpace(reason) == "" {
return domain.CustomVerificationRequest{}, false, domain.ErrVerificationReasonRequired
}
current, err := s.decidableRequest(ctx, st, requestID, version, domain.CustomVerificationRevoked)
if err != nil {
return domain.CustomVerificationRequest{}, false, err
}
if current.Status == domain.CustomVerificationRevoked {
return current, false, nil
}
applier := s.markApplier(st)
stored, changed, err := st.DecideCustomVerificationRequest(ctx, requestID, version, domain.CustomVerificationRevoked, decidedBy, reason, note,
// Written through the decision's own transaction, like the approval above.
func(ctx context.Context, decided domain.CustomVerificationRequest) error {
// A mark that is already gone is not an error: the operator may have
// stripped it directly, and the application still has to reach "revoked".
_, err := applier.RevokeCustomVerification(ctx, decided.VerifierBotID, decided.Peer)
return err
})
if err != nil {
return domain.CustomVerificationRequest{}, false, err
}
if changed {
s.notifyPeer(ctx, stored.Peer, "revoke_request")
s.notifyApplicant(ctx, stored)
}
return stored, changed, nil
}
// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
// enabledVerifier resolves a bot's live verifier status.
//
// "No row" and "switched off" both answer domain.ErrVerifierForbidden, which is
// what the TL edge reports as BOT_VERIFIER_FORBIDDEN: a caller must not be able to
// tell from the error whether a bot was ever a verifier. A stored row that no
// longer validates is refused too -- it could only produce a mark clients cannot
// render.
func (s *Service) enabledVerifier(ctx context.Context, st Store, botID int64) (domain.BotVerifierSettings, error) {
if botID <= 0 {
return domain.BotVerifierSettings{}, domain.ErrVerifierForbidden
}
settings, err := st.BotVerifierSettings(ctx, botID)
if err != nil {
if errors.Is(err, domain.ErrVerifierNotFound) {
return domain.BotVerifierSettings{}, domain.ErrVerifierForbidden
}
return domain.BotVerifierSettings{}, err
}
if settings.BotID != botID || !settings.Enabled {
return domain.BotVerifierSettings{}, domain.ErrVerifierForbidden
}
if err := settings.Validate(); err != nil {
return domain.BotVerifierSettings{}, err
}
return settings, nil
}
// checkVerifierCaller asserts that the caller may act as this verifier bot: it is
// either the bot itself or its owner. A missing bot directory is a configuration
// error rather than a pass -- a security check that cannot run must not succeed.
func (s *Service) checkVerifierCaller(ctx context.Context, verifierBotID, callerUserID int64) error {
if callerUserID <= 0 {
return domain.ErrVerifierForbidden
}
if callerUserID == verifierBotID {
return nil
}
if s.bots == nil {
return fmt.Errorf("bot verification bot directory is not configured")
}
owned, err := s.bots.OwnsBot(ctx, callerUserID, verifierBotID)
if err != nil {
if isNotFoundError(err) {
return domain.ErrVerifierForbidden
}
return err
}
if !owned {
return domain.ErrVerifierForbidden
}
return nil
}
// checkVerifierBot asserts that a verifier candidate is a real bot account.
//
// A plain user account cannot be a verifier: botInfo.verifier_settings only
// exists on a bot, so a marked-up user account would carry a status no client can
// see. Built-in system accounts are refused as well -- they are seeded from
// domain.SystemUserByID and are not the operator's to hand out, and @verifybot in
// particular collecting third-party marks would blur the very distinction between
// the two verification mechanisms.
//
// The one exception is the built-in @verifierbot (domain.VerifierBotUserID),
// which exists for exactly this status: its seed deliberately carries no badge,
// because what makes it a verifier is the operator-granted settings row. Its
// identity is read from the seed rather than the directory, so granting works the
// same way whether or not the account has been materialised yet.
func (s *Service) checkVerifierBot(ctx context.Context, botID int64) error {
if botID <= 0 {
return domain.ErrBotNotFound
}
if system, ok := domain.SystemUserByID(botID); ok {
if botID != domain.VerifierBotUserID || !system.Bot {
return domain.ErrVerificationTargetSystem
}
return nil
}
if s.users == nil {
return fmt.Errorf("bot verification user directory is not configured")
}
user, found, err := s.users.AdminUser(ctx, botID)
if err != nil {
if isNotFoundError(err) {
return domain.ErrBotNotFound
}
return err
}
if !found || user.ID <= 0 || user.Deleted || !user.Bot {
return domain.ErrBotNotFound
}
return nil
}
// checkCatalogueIcon asserts that a verifier's icon is one the operator approved
// and one a client can actually draw.
func (s *Service) checkCatalogueIcon(ctx context.Context, st Store, botID, documentID int64) error {
if documentID <= 0 {
return domain.ErrVerificationIconInvalid
}
icon, err := st.VerificationIconByDocument(ctx, documentID)
if err != nil {
return err
}
if !icon.Active {
return domain.ErrVerificationIconInactive
}
// A reserved entry belongs to one verifier; for anybody else it does not exist.
if !icon.UsableBy(botID) {
return domain.ErrVerificationIconNotFound
}
return s.checkIconDocument(ctx, documentID)
}
// checkIconDocument asserts that an icon's custom emoji document exists.
//
// This is the trap the whole catalogue exists to avoid. Clients resolve the icon
// through messages.getCustomEmojiDocuments, so an id naming no fetchable document
// renders as nothing: the badge is invisible, the peer looks unverified, and the
// only place the mark exists is the database. A document that is not a custom
// emoji is refused for the same reason -- the badge slot draws emoji documents,
// and a sticker or a video would be just as invisible.
func (s *Service) checkIconDocument(ctx context.Context, documentID int64) error {
if documentID <= 0 {
return domain.ErrVerificationIconInvalid
}
if s.documents == nil {
return fmt.Errorf("bot verification icon resolver is not configured")
}
documents, err := s.documents.GetDocuments(ctx, []int64{documentID})
if err != nil {
return err
}
for _, document := range documents {
if document.ID != documentID {
continue
}
if !document.IsCustomEmoji() {
return domain.ErrVerificationIconInvalid
}
return nil
}
return domain.ErrVerificationIconInvalid
}
// markState loads this verifier's current mark on the peer. A missing mark is not
// an error here: "is there one already?" is a question both grant paths ask before
// they decide whether the per-verifier bound applies.
func (s *Service) markState(ctx context.Context, st Store, verifierBotID int64, peer domain.Peer) (domain.CustomVerification, bool, error) {
mark, err := st.CustomVerification(ctx, verifierBotID, peer)
switch {
case err == nil:
return mark, true, nil
case errors.Is(err, domain.ErrCustomVerificationNotFound):
return domain.CustomVerification{}, false, nil
default:
return domain.CustomVerification{}, false, err
}
}
// checkVerifierQuota enforces the configured per-verifier bound. It is only
// consulted before a *new* mark: an existing mark stays re-describable at the
// limit, which is also how the store counts.
func (s *Service) checkVerifierQuota(ctx context.Context, st Store, verifierBotID int64) error {
if s.maxPerVerifier <= 0 {
return nil
}
count, err := st.CountCustomVerifications(ctx, verifierBotID)
if err != nil {
return err
}
if count >= s.maxPerVerifier {
return domain.ErrCustomVerificationLimit
}
return nil
}
// checkRequestRate spends one unit of the applicant's creation budget. It runs
// last among the creation checks, so a refused application never costs budget.
func (s *Service) checkRequestRate(ctx context.Context, applicantUserID int64) error {
if s.limiter == nil || s.requestLimit <= 0 || s.requestWindow <= 0 {
return nil
}
allowed, retryAfter, err := s.limiter.Allow(ctx, requestRateLimitKeyPrefix+strconv.FormatInt(applicantUserID, 10), s.requestLimit, s.requestWindow)
if err != nil {
return err
}
if allowed {
return nil
}
s.log.Debug("bot verification request rate limited",
zap.Int64("applicant_user_id", applicantUserID),
zap.Int("retry_after", retryAfter))
return domain.ErrVerificationRateLimited
}
// decidableRequest loads an application and asserts that the requested decision
// is one it can still take.
//
// An application already in the target status is returned as-is so the caller can
// answer changed=false; anything else is checked against
// domain.CanTransitionCustomVerificationStatus and the optimistic version, which
// the store then re-checks inside its transaction. Checking here first is what
// makes the loser of a two-reviewer race see a version conflict instead of a
// mark applied twice.
func (s *Service) decidableRequest(ctx context.Context, st Store, requestID, version int64, status domain.CustomVerificationRequestStatus) (domain.CustomVerificationRequest, error) {
if requestID <= 0 {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
}
if version <= 0 {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationVersionConflict
}
current, err := st.CustomVerificationRequest(ctx, requestID)
if err != nil {
return domain.CustomVerificationRequest{}, err
}
if current.ID != requestID {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
}
if current.Status == status {
return current, nil
}
if !domain.CanTransitionCustomVerificationStatus(current.Status, status) {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestInvalid
}
if current.Version != version {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationVersionConflict
}
return current, nil
}
// approvedDescription resolves the text an approved mark carries.
//
// The applicant's requested description is honoured only while the verifier is
// allowed to write its own; if the operator revoked that permission after the
// application was filed, the operator default is applied instead. Refusing the
// approval would be worse: the application would be stuck in a state no reviewer
// could clear, over a permission the applicant never controlled.
func (s *Service) approvedDescription(settings domain.BotVerifierSettings, requested string) string {
description, err := settings.DescriptionFor(requested)
if err == nil {
return description
}
return strings.TrimSpace(settings.DefaultDescription)
}
// resolvePeer loads the target peer and reports whether it can carry a mark at
// all, together with the title/username snapshot an application records.
func (s *Service) resolvePeer(ctx context.Context, peer domain.Peer) (peerSnapshot, error) {
if !markablePeer(peer) {
return peerSnapshot{}, domain.ErrCustomVerificationTargetInvalid
}
switch peer.Type {
case domain.PeerTypeUser:
// A built-in account's identity is seeded, not granted: a third-party
// verifier must not be able to decorate @verifybot or the service account.
if domain.IsSystemUserID(peer.ID) {
return peerSnapshot{}, domain.ErrCustomVerificationTargetInvalid
}
if s.users == nil {
return peerSnapshot{}, fmt.Errorf("bot verification user directory is not configured")
}
user, found, err := s.users.AdminUser(ctx, peer.ID)
if err != nil {
return peerSnapshot{}, mapLookupError(err)
}
if !found || user.ID <= 0 || user.Deleted {
return peerSnapshot{}, domain.ErrCustomVerificationTargetInvalid
}
return peerSnapshot{
peer: domain.Peer{Type: domain.PeerTypeUser, ID: user.ID},
title: userTitle(user),
username: user.Username,
}, nil
case domain.PeerTypeChannel:
if s.channels == nil {
return peerSnapshot{}, fmt.Errorf("bot verification channel directory is not configured")
}
channel, err := s.channels.GetChannelByID(ctx, peer.ID)
if err != nil {
return peerSnapshot{}, mapLookupError(err)
}
if channel.ID <= 0 || channel.Deleted {
return peerSnapshot{}, domain.ErrCustomVerificationTargetInvalid
}
return peerSnapshot{
peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID},
title: truncateRunes(strings.TrimSpace(channel.Title), maxPeerTitleRunes),
username: channel.Username,
}, nil
default:
return peerSnapshot{}, domain.ErrCustomVerificationTargetInvalid
}
}
// peerSnapshot is a freshly resolved target: the canonical peer plus the
// identity fields an application stores for the audit trail.
type peerSnapshot struct {
peer domain.Peer
title string
username string
}
// controls reports whether the applicant may file for this peer.
//
// Each authority stays where it belongs: bot ownership is the bot aggregate's
// answer, channel rights are the channel aggregate's membership, and a user
// target is only controlled by being that user. The channel branch accepts the
// creator and an active administrator carrying change_info -- the same right that
// governs the channel's public identity (title, username), which is what a badge
// on it is.
func (s *Service) controls(ctx context.Context, applicantUserID int64, peer domain.Peer) (bool, error) {
if applicantUserID <= 0 {
return false, domain.ErrCustomVerificationRequestInvalid
}
switch peer.Type {
case domain.PeerTypeUser:
if applicantUserID == peer.ID {
return true, nil
}
if s.bots == nil {
return false, fmt.Errorf("bot verification bot directory is not configured")
}
owned, err := s.bots.OwnsBot(ctx, applicantUserID, peer.ID)
if err != nil {
if isNotFoundError(err) {
return false, nil
}
return false, err
}
return owned, nil
case domain.PeerTypeChannel:
if s.channels == nil {
return false, fmt.Errorf("bot verification channel directory is not configured")
}
channel, err := s.channels.GetChannelByID(ctx, peer.ID)
if err != nil {
if isNotFoundError(err) {
return false, nil
}
return false, err
}
if channel.CreatorUserID == applicantUserID {
return true, nil
}
member, err := s.channels.GetParticipant(ctx, applicantUserID, peer.ID, applicantUserID)
if err != nil {
if isNotMemberError(err) {
return false, nil
}
return false, err
}
if member.Status != domain.ChannelMemberActive {
return false, nil
}
switch member.Role {
case domain.ChannelRoleCreator:
return true, nil
case domain.ChannelRoleAdmin:
return member.AdminRights.ChangeInfo, nil
default:
return false, nil
}
default:
return false, domain.ErrCustomVerificationTargetInvalid
}
}
// ---------------------------------------------------------------------------
// Notifications
// ---------------------------------------------------------------------------
// notifyPeer drops the cached peer projections and pushes the mark change to
// online clients. The mutation has already committed, so a failure is logged and
// swallowed: retrying the mutation would be wrong, and the next authoritative peer
// read repairs the projection anyway.
func (s *Service) notifyPeer(ctx context.Context, peer domain.Peer, action string) {
if s == nil || s.peers == nil || peer.ID <= 0 {
return
}
if err := s.peers.NotifyPeerBotVerification(ctx, peer); err != nil {
s.log.Warn("bot verification peer notification failed",
zap.String("action", action),
zap.String("peer_type", string(peer.Type)),
zap.Int64("peer_id", peer.ID),
zap.Error(err))
}
}
// notifyApplicant delivers the decision to the applicant as a message from the
// verifier bot. Like the peer push it is best effort: an applicant who blocked
// the bot must not be able to undo a committed decision.
func (s *Service) notifyApplicant(ctx context.Context, req domain.CustomVerificationRequest) {
if s == nil || s.applicant == nil || req.ApplicantUserID <= 0 {
return
}
if err := s.applicant.SendVerificationDecision(ctx, req.ApplicantUserID, req); err != nil {
s.log.Warn("bot verification applicant notification failed",
zap.Int64("request_id", req.ID),
zap.Int64("applicant_user_id", req.ApplicantUserID),
zap.String("status", string(req.Status)),
zap.Error(err))
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// markablePeer mirrors the store's peer_type CHECK: only users (including bots)
// and channels carry a third-party mark.
func markablePeer(peer domain.Peer) bool {
return markablePeerType(peer.Type) && peer.ID > 0
}
func markablePeerType(peerType domain.PeerType) bool {
return peerType == domain.PeerTypeUser || peerType == domain.PeerTypeChannel
}
// mapLookupError folds a peer aggregate's "no such peer" answers into this
// package's vocabulary and leaves real failures alone.
func mapLookupError(err error) error {
if isNotFoundError(err) {
return domain.ErrCustomVerificationTargetInvalid
}
return err
}
func isNotFoundError(err error) bool {
return errors.Is(err, domain.ErrUserNotFound) ||
errors.Is(err, domain.ErrBotNotFound) ||
errors.Is(err, domain.ErrChannelInvalid)
}
// isNotMemberError reports the channel aggregate's "this account is not in that
// channel" answers, which mean "does not control it" rather than a failure.
func isNotMemberError(err error) bool {
return isNotFoundError(err) ||
errors.Is(err, domain.ErrUserNotParticipant) ||
errors.Is(err, domain.ErrChannelAdminRequired)
}
func userTitle(user domain.User) string {
title := strings.TrimSpace(strings.TrimSpace(user.FirstName) + " " + strings.TrimSpace(user.LastName))
return truncateRunes(title, maxPeerTitleRunes)
}
func clampLimit(limit, fallback, maximum int) int {
if limit <= 0 {
return fallback
}
if limit > maximum {
return maximum
}
return limit
}
func truncateRunes(value string, max int) string {
if max <= 0 || utf8.RuneCountInString(value) <= max {
return value
}
return string([]rune(value)[:max])
}