413 lines
14 KiB
Go
413 lines
14 KiB
Go
// Package stargifts implements the durable Star Gift catalog and received-gift state.
|
|
package stargifts
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
// BlobBackend is the content-addressed media boundary used by the catalog importer.
|
|
type BlobBackend interface {
|
|
Name() string
|
|
Put(ctx context.Context, data []byte) (string, error)
|
|
Get(ctx context.Context, objectKey string) ([]byte, error)
|
|
}
|
|
|
|
type Service struct {
|
|
store store.StarGiftStore
|
|
upgrades store.StarGiftUpgradeStore
|
|
blobs BlobBackend
|
|
dc int
|
|
|
|
mu sync.RWMutex
|
|
built bool
|
|
gifts []domain.StarGift
|
|
byID map[int64]domain.StarGift
|
|
hash int
|
|
}
|
|
|
|
type Option func(*Service)
|
|
|
|
func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option {
|
|
return func(service *Service) { service.upgrades = upgrades }
|
|
}
|
|
|
|
func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service {
|
|
service := &Service{store: st, blobs: blobs, dc: dc}
|
|
for _, opt := range opts {
|
|
opt(service)
|
|
}
|
|
return service
|
|
}
|
|
|
|
func (s *Service) ensureCatalog(ctx context.Context) error {
|
|
s.mu.RLock()
|
|
built := s.built
|
|
s.mu.RUnlock()
|
|
if built {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.built {
|
|
return nil
|
|
}
|
|
if s.store == nil {
|
|
return fmt.Errorf("star gift store is not configured")
|
|
}
|
|
gifts, err := s.store.Catalog(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.gifts = gifts
|
|
s.byID = make(map[int64]domain.StarGift, len(gifts))
|
|
for _, gift := range gifts {
|
|
s.byID[gift.ID] = gift
|
|
}
|
|
s.hash = domain.StarGiftCatalogHash(gifts)
|
|
s.built = true
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) Catalog(ctx context.Context) ([]domain.StarGift, error) {
|
|
if err := s.ensureCatalog(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return append([]domain.StarGift(nil), s.gifts...), nil
|
|
}
|
|
|
|
func (s *Service) CatalogHash(ctx context.Context) (int, error) {
|
|
if err := s.ensureCatalog(ctx); err != nil {
|
|
return 0, err
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.hash, nil
|
|
}
|
|
|
|
func (s *Service) GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error) {
|
|
if err := s.ensureCatalog(ctx); err != nil {
|
|
return domain.StarGift{}, false, err
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
gift, ok := s.byID[id]
|
|
return gift, ok, nil
|
|
}
|
|
|
|
func (s *Service) GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) {
|
|
if s == nil || s.store == nil {
|
|
return domain.StarGift{}, false, nil
|
|
}
|
|
return s.store.CatalogRevision(ctx, revisionID)
|
|
}
|
|
|
|
// InvalidateStarGiftCatalog implements the shared PostgreSQL read-model listener boundary.
|
|
func (s *Service) InvalidateStarGiftCatalog() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.built = false
|
|
s.gifts = nil
|
|
s.byID = nil
|
|
s.hash = 0
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *Service) FlushStarGiftCatalog() { s.InvalidateStarGiftCatalog() }
|
|
|
|
func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
|
if s == nil || s.store == nil || s.blobs == nil {
|
|
return domain.StarGiftCatalogEntry{}, fmt.Errorf("star gift catalog importer is not configured")
|
|
}
|
|
write.Title = strings.TrimSpace(write.Title)
|
|
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
|
|
write.Animation.Width != 512 || write.Animation.Height != 512 || len(write.Animation.TGS) == 0 ||
|
|
len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes {
|
|
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid
|
|
}
|
|
objectKey, err := s.blobs.Put(ctx, write.Animation.TGS)
|
|
if err != nil {
|
|
return domain.StarGiftCatalogEntry{}, fmt.Errorf("store star gift animation: %w", err)
|
|
}
|
|
documentID, err := randomPositiveInt64()
|
|
if err != nil {
|
|
return domain.StarGiftCatalogEntry{}, err
|
|
}
|
|
accessHash, err := randomPositiveInt64()
|
|
if err != nil {
|
|
return domain.StarGiftCatalogEntry{}, err
|
|
}
|
|
fileReference := make([]byte, 16)
|
|
if _, err := rand.Read(fileReference); err != nil {
|
|
return domain.StarGiftCatalogEntry{}, fmt.Errorf("generate star gift file reference: %w", err)
|
|
}
|
|
write.Document = domain.Document{
|
|
ID: documentID,
|
|
AccessHash: accessHash,
|
|
FileReference: fileReference,
|
|
Date: int(time.Now().Unix()),
|
|
MimeType: "application/x-tgsticker",
|
|
Size: int64(len(write.Animation.TGS)),
|
|
DCID: s.dc,
|
|
Attributes: []domain.DocumentAttribute{
|
|
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
|
|
{Kind: domain.DocAttrSticker, Alt: "🎁"},
|
|
{Kind: domain.DocAttrFilename, FileName: "gift.tgs"},
|
|
},
|
|
}
|
|
write.Blob = domain.FileBlob{
|
|
LocationKey: fmt.Sprintf("doc:%d", documentID),
|
|
Backend: domain.MediaBackend(s.blobs.Name()),
|
|
ObjectKey: objectKey,
|
|
Size: int64(len(write.Animation.TGS)),
|
|
SHA256: append([]byte(nil), write.Animation.SHA256...),
|
|
MimeType: "application/x-tgsticker",
|
|
}
|
|
entry, err := s.store.CreateCatalogRevision(ctx, write)
|
|
if err != nil {
|
|
return domain.StarGiftCatalogEntry{}, err
|
|
}
|
|
s.InvalidateStarGiftCatalog()
|
|
return entry, nil
|
|
}
|
|
|
|
func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
|
|
changed, err := s.store.SetCatalogEnabled(ctx, giftID, enabled)
|
|
if err == nil {
|
|
s.InvalidateStarGiftCatalog()
|
|
}
|
|
return changed, err
|
|
}
|
|
|
|
func (s *Service) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) {
|
|
changed, err := s.store.SetCatalogSortOrder(ctx, giftID, sortOrder)
|
|
if err == nil {
|
|
s.InvalidateStarGiftCatalog()
|
|
}
|
|
return changed, err
|
|
}
|
|
|
|
func (s *Service) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) {
|
|
return s.store.AnimationJSON(ctx, giftID)
|
|
}
|
|
|
|
func (s *Service) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
|
if s == nil || s.store == nil {
|
|
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured")
|
|
}
|
|
revision, err := s.store.PublishCollectibleRevision(ctx, write)
|
|
if err == nil {
|
|
s.InvalidateStarGiftCatalog()
|
|
}
|
|
return revision, err
|
|
}
|
|
|
|
// CreateCollectibleRevision materializes the normalized model/pattern animations and then
|
|
// atomically publishes the complete immutable attribute pool. Callers must pass animations
|
|
// produced by PrepareAnimation; partial revisions are never exposed to clients.
|
|
func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
|
if s == nil || s.store == nil || s.blobs == nil {
|
|
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured")
|
|
}
|
|
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
|
|
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
|
|
return domain.StarGiftCollectibleRevision{}, err
|
|
}
|
|
materialize := func(attributes []domain.StarGiftCollectibleAttribute) error {
|
|
for i := range attributes {
|
|
animation := attributes[i].Animation
|
|
if animation == nil {
|
|
return domain.ErrStarGiftCollectibleInvalid
|
|
}
|
|
objectKey, err := s.blobs.Put(ctx, animation.TGS)
|
|
if err != nil {
|
|
return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err)
|
|
}
|
|
documentID, err := randomPositiveInt64()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
accessHash, err := randomPositiveInt64()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fileReference := make([]byte, 16)
|
|
if _, err := rand.Read(fileReference); err != nil {
|
|
return fmt.Errorf("generate collectible file reference: %w", err)
|
|
}
|
|
attributes[i].Document = &domain.Document{
|
|
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
|
|
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
|
|
Size: int64(len(animation.TGS)), DCID: s.dc,
|
|
Attributes: []domain.DocumentAttribute{
|
|
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
|
|
{Kind: domain.DocAttrSticker, Alt: "🎁"},
|
|
{Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"},
|
|
},
|
|
}
|
|
attributes[i].Blob = &domain.FileBlob{
|
|
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
|
|
ObjectKey: objectKey, Size: int64(len(animation.TGS)),
|
|
SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if err := materialize(write.Models); err != nil {
|
|
return domain.StarGiftCollectibleRevision{}, err
|
|
}
|
|
if err := materialize(write.Patterns); err != nil {
|
|
return domain.StarGiftCollectibleRevision{}, err
|
|
}
|
|
return s.PublishCollectibleRevision(ctx, write)
|
|
}
|
|
|
|
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
|
|
if s == nil || s.store == nil || giftID <= 0 {
|
|
return domain.StarGiftUpgradePreview{}, false, nil
|
|
}
|
|
revision, ok, err := s.store.ActiveCollectibleRevision(ctx, giftID)
|
|
if err != nil || !ok || !revision.Published {
|
|
return domain.StarGiftUpgradePreview{}, false, err
|
|
}
|
|
return domain.StarGiftUpgradePreview{
|
|
GiftID: giftID, Revision: revision.Revision, UpgradeStars: revision.UpgradeStars, SupplyTotal: revision.SupplyTotal,
|
|
Issued: revision.Issued, Models: revision.Models, Patterns: revision.Patterns, Backdrops: revision.Backdrops,
|
|
SlugPrefix: revision.SlugPrefix,
|
|
}, true, nil
|
|
}
|
|
|
|
func (s *Service) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
|
|
if s == nil || s.store == nil || len(giftIDs) == 0 {
|
|
return map[int64]domain.StarGiftCollectibleAvailability{}, nil
|
|
}
|
|
return s.store.CollectibleAvailability(ctx, giftIDs)
|
|
}
|
|
|
|
func (s *Service) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
|
|
if s == nil || s.store == nil {
|
|
return nil, false, nil
|
|
}
|
|
return s.store.CollectibleAnimationJSON(ctx, giftID, kind, attributeID)
|
|
}
|
|
|
|
func (s *Service) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
|
|
if s == nil || s.store == nil {
|
|
return domain.UniqueStarGift{}, false, nil
|
|
}
|
|
return s.store.UniqueBySlug(ctx, slug)
|
|
}
|
|
|
|
func (s *Service) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
|
|
if s == nil || s.store == nil {
|
|
return domain.UniqueStarGift{}, false, nil
|
|
}
|
|
return s.store.UniqueByID(ctx, uniqueGiftID)
|
|
}
|
|
|
|
func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
|
|
if s == nil || s.store == nil || len(uniqueGiftIDs) == 0 {
|
|
return map[int64]domain.UniqueStarGift{}, nil
|
|
}
|
|
return s.store.UniqueByIDs(ctx, uniqueGiftIDs)
|
|
}
|
|
|
|
func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
|
|
if s == nil || s.upgrades == nil {
|
|
return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured")
|
|
}
|
|
result, err := s.upgrades.UpgradeStarGift(ctx, req)
|
|
if err == nil {
|
|
s.InvalidateStarGiftCatalog()
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
|
|
return s.store.ListCollections(ctx, owner)
|
|
}
|
|
|
|
func (s *Service) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
|
|
return s.store.CreateCollection(ctx, owner, title, savedGiftIDs)
|
|
}
|
|
|
|
func (s *Service) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
|
|
return s.store.UpdateCollection(ctx, owner, collectionID, patch)
|
|
}
|
|
|
|
func (s *Service) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
|
|
return s.store.DeleteCollection(ctx, owner, collectionID)
|
|
}
|
|
|
|
func (s *Service) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
|
|
return s.store.ReorderCollections(ctx, owner, collectionIDs)
|
|
}
|
|
|
|
func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
|
|
return s.store.SetPinned(ctx, owner, savedGiftIDs)
|
|
}
|
|
|
|
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
|
|
return s.store.Create(ctx, gift)
|
|
}
|
|
|
|
func (s *Service) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
|
|
return s.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
|
|
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
|
|
})
|
|
}
|
|
|
|
func (s *Service) ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
|
|
offset := filter.Offset
|
|
if len(offset) > domain.MaxStarGiftsOffsetBytes {
|
|
filter.Offset = ""
|
|
}
|
|
if filter.Limit <= 0 || filter.Limit > domain.MaxSavedStarGiftsLimit {
|
|
filter.Limit = domain.MaxSavedStarGiftsLimit
|
|
}
|
|
return s.store.ListByOwnerFiltered(ctx, filter)
|
|
}
|
|
|
|
func (s *Service) GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
|
|
return s.store.GetByRef(ctx, ref)
|
|
}
|
|
|
|
func (s *Service) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
|
|
return s.store.ResolveSavedIDs(ctx, owner, refs)
|
|
}
|
|
|
|
func (s *Service) CountSaved(ctx context.Context, owner domain.Peer) (int, error) {
|
|
return s.store.CountByOwner(ctx, owner)
|
|
}
|
|
|
|
func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
|
|
return s.store.SetUnsaved(ctx, ref, unsaved)
|
|
}
|
|
|
|
func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
|
return s.store.MarkConverted(ctx, ref)
|
|
}
|
|
|
|
func randomPositiveInt64() (int64, error) {
|
|
var raw [8]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return 0, fmt.Errorf("generate star gift id: %w", err)
|
|
}
|
|
id := int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
|
|
if id == 0 {
|
|
id = 1
|
|
}
|
|
return id, nil
|
|
}
|