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>
552 lines
18 KiB
Go
552 lines
18 KiB
Go
// Package usernames implements the collectible (Fragment-style) username
|
|
// registry use cases: reading a peer's username vector, toggling and reordering
|
|
// the collectible rows a client owns, and the operator lifecycle that mints,
|
|
// transfers, revokes and burns the assets behind those rows.
|
|
//
|
|
// The service owns normalisation and validation. Every entry point normalises
|
|
// the name through domain.NormalizeUsername and runs the domain Validate()
|
|
// checks before the store is touched, so an RPC handler, the admin API and a
|
|
// unit test all reject the same shapes with the same errors.
|
|
package usernames
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/links"
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
const (
|
|
// defaultListLimit is the admin listing page size used when the caller does
|
|
// not bound the query itself.
|
|
defaultListLimit = 50
|
|
// maxListLimit bounds one listing page regardless of the requested limit.
|
|
maxListLimit = 200
|
|
// defaultTransferLimit / maxTransferLimit bound the provenance log page.
|
|
defaultTransferLimit = 50
|
|
maxTransferLimit = 200
|
|
// usernamePlaceholder is the substitution supported by the operator URL
|
|
// template, e.g. https://example.org/nft/{username}.
|
|
usernamePlaceholder = "{username}"
|
|
// defaultCollectibleURLPath is the public-link route used when no operator
|
|
// template is configured.
|
|
defaultCollectibleURLPath = "nft/username"
|
|
)
|
|
|
|
// ErrPeerInvalid rejects a registry mutation for a peer that cannot hold
|
|
// usernames. Only users and channels have a username registry; anything else is
|
|
// a caller bug rather than a client-visible protocol state.
|
|
var ErrPeerInvalid = errors.New("username peer invalid")
|
|
|
|
// PeerUsernameNotifier is the domain-only edge hook invoked after a username
|
|
// registry mutation. The RPC router implements it: it invalidates the cached
|
|
// peer projections and pushes the username change to online clients, exactly
|
|
// like the account.updateUsername path does for the editable slot. Keeping it an
|
|
// injected port means this package never depends on the protocol edge.
|
|
type PeerUsernameNotifier interface {
|
|
NotifyPeerUsernamesChanged(ctx context.Context, peer domain.Peer) error
|
|
}
|
|
|
|
// Service is the collectible username use-case layer.
|
|
type Service struct {
|
|
registry store.UsernameRegistryStore
|
|
collectibles store.CollectibleUsernameStore
|
|
notifier PeerUsernameNotifier
|
|
|
|
// urlTemplate is the operator-provided collectible landing URL template;
|
|
// publicBaseURL is the fallback root the default route is built from.
|
|
urlTemplate string
|
|
publicBaseURL string
|
|
|
|
now func() time.Time
|
|
log *zap.Logger
|
|
}
|
|
|
|
// Option adjusts optional service dependencies.
|
|
type Option func(*Service)
|
|
|
|
// WithRegistryStore injects the peer username registry reader/writer.
|
|
func WithRegistryStore(registry store.UsernameRegistryStore) Option {
|
|
return func(s *Service) { s.registry = registry }
|
|
}
|
|
|
|
// WithCollectibleStore injects the collectible asset lifecycle store.
|
|
func WithCollectibleStore(collectibles store.CollectibleUsernameStore) Option {
|
|
return func(s *Service) { s.collectibles = collectibles }
|
|
}
|
|
|
|
// WithNotifier injects the edge invalidation/update hook.
|
|
func WithNotifier(notifier PeerUsernameNotifier) Option {
|
|
return func(s *Service) { s.notifier = notifier }
|
|
}
|
|
|
|
// WithURLTemplate configures the collectible asset landing URL template. An
|
|
// empty template keeps the public-link default route.
|
|
func WithURLTemplate(template string) Option {
|
|
return func(s *Service) { s.urlTemplate = strings.TrimSpace(template) }
|
|
}
|
|
|
|
// WithPublicBaseURL configures the public-link root the default collectible URL
|
|
// route is derived from.
|
|
func WithPublicBaseURL(baseURL string) Option {
|
|
return func(s *Service) { s.publicBaseURL = strings.TrimSpace(baseURL) }
|
|
}
|
|
|
|
// 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 collectible username service. Every dependency is
|
|
// optional: a service without stores answers with a configuration error instead
|
|
// of panicking, which keeps partial deployments diagnosable.
|
|
func NewService(opts ...Option) *Service {
|
|
s := &Service{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()
|
|
}
|
|
return s
|
|
}
|
|
|
|
// SetPeerUsernameNotifier injects the edge hook after construction. The RPC
|
|
// router is built after the app services, so the notification port is bound
|
|
// here rather than through NewService.
|
|
func (s *Service) SetPeerUsernameNotifier(notifier PeerUsernameNotifier) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.notifier = notifier
|
|
}
|
|
|
|
// Configured reports whether both registries are installed.
|
|
func (s *Service) Configured() bool {
|
|
return s != nil && s.registry != nil && s.collectibles != nil
|
|
}
|
|
|
|
func (s *Service) registryStore() (store.UsernameRegistryStore, error) {
|
|
if s == nil || s.registry == nil {
|
|
return nil, fmt.Errorf("username registry store is not configured")
|
|
}
|
|
return s.registry, nil
|
|
}
|
|
|
|
func (s *Service) collectibleStore() (store.CollectibleUsernameStore, error) {
|
|
if s == nil || s.collectibles == nil {
|
|
return nil, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
return s.collectibles, nil
|
|
}
|
|
|
|
// PeerUsernames returns the peer's username vector in projection order.
|
|
func (s *Service) PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error) {
|
|
registry, err := s.registryStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !validPeer(peer) {
|
|
return nil, nil
|
|
}
|
|
list, err := registry.PeerUsernames(ctx, peer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return domain.SortUsernames(list), nil
|
|
}
|
|
|
|
// UsernamesBatch resolves several peers in one round trip. Peers holding no
|
|
// usernames are absent from the result.
|
|
func (s *Service) UsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
|
|
registry, err := s.registryStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
unique := make([]domain.Peer, 0, len(peers))
|
|
seen := make(map[domain.Peer]struct{}, len(peers))
|
|
for _, peer := range peers {
|
|
if !validPeer(peer) {
|
|
continue
|
|
}
|
|
if _, ok := seen[peer]; ok {
|
|
continue
|
|
}
|
|
seen[peer] = struct{}{}
|
|
unique = append(unique, peer)
|
|
}
|
|
if len(unique) == 0 {
|
|
return map[domain.Peer][]domain.Username{}, nil
|
|
}
|
|
batch, err := registry.PeerUsernamesBatch(ctx, unique)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[domain.Peer][]domain.Username, len(batch))
|
|
for peer, list := range batch {
|
|
if len(list) == 0 {
|
|
continue
|
|
}
|
|
out[peer] = domain.SortUsernames(list)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ToggleUsername activates or deactivates one collectible row. The editable
|
|
// slot is never touched: it is owned by account/channels.updateUsername.
|
|
func (s *Service) ToggleUsername(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error) {
|
|
registry, err := s.registryStore()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !validPeer(peer) {
|
|
return false, ErrPeerInvalid
|
|
}
|
|
username = domain.NormalizeUsername(username)
|
|
if username == "" {
|
|
return false, domain.ErrUsernameInvalid
|
|
}
|
|
current, err := registry.PeerUsernames(ctx, peer)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
|
|
return false, err
|
|
}
|
|
changed, err := registry.SetUsernameActive(ctx, peer, username, active)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if changed {
|
|
s.notifyPeers(ctx, peer)
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
// ReorderUsernames rewrites the collectible order. order must be a permutation
|
|
// of the peer's collectible usernames; the editable slot always projects first.
|
|
func (s *Service) ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error) {
|
|
registry, err := s.registryStore()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !validPeer(peer) {
|
|
return false, ErrPeerInvalid
|
|
}
|
|
normalized := make([]string, 0, len(order))
|
|
for _, name := range order {
|
|
normalized = append(normalized, domain.NormalizeUsername(name))
|
|
}
|
|
current, err := registry.PeerUsernames(ctx, peer)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if err := domain.ValidateUsernameReorder(current, normalized); err != nil {
|
|
return false, err
|
|
}
|
|
changed, err := registry.ReorderUsernames(ctx, peer, normalized)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if changed {
|
|
s.notifyPeers(ctx, peer)
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
// DeactivateAllUsernames clears the active flag on every collectible row.
|
|
func (s *Service) DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error) {
|
|
registry, err := s.registryStore()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !validPeer(peer) {
|
|
return false, ErrPeerInvalid
|
|
}
|
|
changed, err := registry.DeactivateAllUsernames(ctx, peer)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if changed {
|
|
s.notifyPeers(ctx, peer)
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
// CollectibleInfo returns the fragment.collectibleInfo projection for a name.
|
|
func (s *Service) CollectibleInfo(ctx context.Context, username string) (domain.CollectibleInfo, error) {
|
|
asset, err := s.Collectible(ctx, username)
|
|
if err != nil {
|
|
return domain.CollectibleInfo{}, err
|
|
}
|
|
return asset.Info(), nil
|
|
}
|
|
|
|
// Collectible looks up the asset behind a collectible username.
|
|
func (s *Service) Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error) {
|
|
collectibles, err := s.collectibleStore()
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, err
|
|
}
|
|
username = domain.NormalizeUsername(username)
|
|
if !domain.ValidCollectibleUsername(username) {
|
|
return domain.CollectibleUsername{}, domain.ErrUsernameInvalid
|
|
}
|
|
return collectibles.CollectibleUsername(ctx, username)
|
|
}
|
|
|
|
// Mint creates a collectible asset, optionally assigning it in the same
|
|
// command. An empty URL is rendered from the configured template and an unset
|
|
// purchase date is stamped with the service clock, so the stored provenance is
|
|
// always complete and reproducible.
|
|
func (s *Service) Mint(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
|
collectibles, err := s.collectibleStore()
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
req.Actor = strings.TrimSpace(req.Actor)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
|
if strings.TrimSpace(req.URL) == "" {
|
|
req.URL = s.CollectibleURL(req.Username)
|
|
}
|
|
if req.PurchaseDate.IsZero() {
|
|
req.PurchaseDate = s.now().UTC()
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
asset, created, err := collectibles.MintCollectibleUsername(ctx, req)
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
if created {
|
|
s.notifyPeers(ctx, req.Owner, asset.Owner)
|
|
}
|
|
return asset, created, nil
|
|
}
|
|
|
|
// Transfer moves the asset to req.To, either out of the vault or from the
|
|
// current holder. Both the previous and the new holder are invalidated.
|
|
func (s *Service) Transfer(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
|
collectibles, err := s.collectibleStore()
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
req.Actor = strings.TrimSpace(req.Actor)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
|
if err := req.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
|
asset, changed, err := collectibles.TransferCollectibleUsername(ctx, req)
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
if changed {
|
|
s.notifyPeers(ctx, previousOwner, req.To, asset.Owner)
|
|
}
|
|
return asset, changed, nil
|
|
}
|
|
|
|
// Revoke returns the asset to the vault, or burns it when req.Burn is set.
|
|
func (s *Service) Revoke(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
|
collectibles, err := s.collectibleStore()
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
req.Actor = strings.TrimSpace(req.Actor)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
|
if err := req.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
|
asset, changed, err := collectibles.RevokeCollectibleUsername(ctx, req)
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
if changed {
|
|
s.notifyPeers(ctx, previousOwner, asset.Owner)
|
|
}
|
|
return asset, changed, nil
|
|
}
|
|
|
|
// Delete removes an asset outright, releasing its name and discarding its
|
|
// provenance. Revoke with Burn retires an asset but keeps the history; this is
|
|
// the operator's escape hatch for an asset issued by mistake.
|
|
//
|
|
// The previous owner is notified exactly like a revoke: the peer's projection
|
|
// still carries the username until it is invalidated.
|
|
func (s *Service) Delete(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
|
collectibles, err := s.collectibleStore()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
req.Actor = strings.TrimSpace(req.Actor)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
|
if err := req.Validate(); err != nil {
|
|
return false, err
|
|
}
|
|
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
|
deleted, err := collectibles.DeleteCollectibleUsername(ctx, req)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if deleted {
|
|
s.notifyPeers(ctx, previousOwner, domain.Peer{})
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
// List is the admin listing query. The limit is always bounded, so an
|
|
// unfiltered operator request can never ask the store for an unbounded scan.
|
|
func (s *Service) List(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
|
collectibles, err := s.collectibleStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if filter.Status != "" && !filter.Status.Valid() {
|
|
return nil, domain.ErrCollectibleUsernameStateInvalid
|
|
}
|
|
if filter.Owner.Type != "" && !validPeer(filter.Owner) {
|
|
return nil, domain.ErrCollectibleUsernameStateInvalid
|
|
}
|
|
filter.Query = domain.NormalizeUsername(filter.Query)
|
|
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
|
|
return collectibles.ListCollectibleUsernames(ctx, filter)
|
|
}
|
|
|
|
// Transfers returns the provenance log of one asset, newest first.
|
|
func (s *Service) Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
|
collectibles, err := s.collectibleStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if collectibleID <= 0 {
|
|
return nil, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
return collectibles.CollectibleUsernameTransfers(ctx, collectibleID, clampLimit(limit, defaultTransferLimit, maxTransferLimit))
|
|
}
|
|
|
|
// CollectibleURL renders the asset landing URL for a name. The operator
|
|
// template wins; {username} is substituted when present and appended as a path
|
|
// segment when it is not. Without a template the public-link default route is
|
|
// used, and without any configured root the URL stays empty rather than
|
|
// pointing at an unrelated host.
|
|
func (s *Service) CollectibleURL(username string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
username = domain.NormalizeUsername(username)
|
|
if username == "" {
|
|
return ""
|
|
}
|
|
template := strings.TrimSpace(s.urlTemplate)
|
|
if template != "" {
|
|
if strings.Contains(template, usernamePlaceholder) {
|
|
return strings.ReplaceAll(template, usernamePlaceholder, username)
|
|
}
|
|
return strings.TrimRight(template, "/") + "/" + username
|
|
}
|
|
if strings.TrimSpace(s.publicBaseURL) == "" {
|
|
return ""
|
|
}
|
|
return links.Build(s.publicBaseURL, defaultCollectibleURLPath+"/"+username, nil)
|
|
}
|
|
|
|
// currentOwner reads the holder before a lifecycle mutation so the previous
|
|
// peer's projection is invalidated too. It is best effort: a missing or
|
|
// unreadable asset only means there is no extra peer to notify, and the
|
|
// mutation itself remains the authority.
|
|
func (s *Service) currentOwner(ctx context.Context, collectibles store.CollectibleUsernameStore, username string) domain.Peer {
|
|
asset, err := collectibles.CollectibleUsername(ctx, username)
|
|
if err != nil {
|
|
if !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
|
s.log.Debug("read collectible username owner before mutation",
|
|
zap.String("username", username),
|
|
zap.Error(err))
|
|
}
|
|
return domain.Peer{}
|
|
}
|
|
if !asset.Owned() {
|
|
return domain.Peer{}
|
|
}
|
|
return asset.Owner
|
|
}
|
|
|
|
// notifyPeers invalidates projections and pushes updates for every distinct
|
|
// affected peer. Notification is best effort: the registry mutation already
|
|
// committed, and a failed push converges through the client's next
|
|
// authoritative peer read.
|
|
func (s *Service) notifyPeers(ctx context.Context, peers ...domain.Peer) {
|
|
if s == nil || s.notifier == nil {
|
|
return
|
|
}
|
|
seen := make(map[domain.Peer]struct{}, len(peers))
|
|
for _, peer := range peers {
|
|
if !validPeer(peer) {
|
|
continue
|
|
}
|
|
if _, ok := seen[peer]; ok {
|
|
continue
|
|
}
|
|
seen[peer] = struct{}{}
|
|
if err := s.notifier.NotifyPeerUsernamesChanged(ctx, peer); err != nil {
|
|
s.log.Warn("notify collectible username change failed",
|
|
zap.String("peer_type", string(peer.Type)),
|
|
zap.Int64("peer_id", peer.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func validPeer(peer domain.Peer) bool {
|
|
switch peer.Type {
|
|
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
|
return peer.ID > 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func clampLimit(limit, fallback, maximum int) int {
|
|
if limit <= 0 {
|
|
return fallback
|
|
}
|
|
if limit > maximum {
|
|
return maximum
|
|
}
|
|
return limit
|
|
}
|