removed default telegram gifts

This commit is contained in:
onysd 2026-07-22 20:20:16 +03:00
parent 139967399d
commit b1836c78e8
25109 changed files with 1040 additions and 757922 deletions

View file

@ -1,83 +0,0 @@
package admin
import (
"context"
"os"
"testing"
stargiftapp "telesrv/internal/app/stargifts"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
"telesrv/internal/store/memory"
)
// This opt-in regression uses the exact official snapshot reported by the
// Party Sparkler import issue. It crosses official catalog verification,
// admin mapping, animation materialization and the complete store validator.
func TestConfiguredOfficialPartySparklerImport(t *testing.T) {
root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR")
if root == "" {
t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set")
}
ctx := context.Background()
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Gifts: giftService,
OfficialGifts: officialgifts.New(root),
Now: fixedNow,
})
result, err := svc.ImportOfficialStarGift(ctx, ImportOfficialStarGiftRequest{
CommandMeta: CommandMeta{
CommandID: "exec-party-sparkler-snapshot-regression",
Actor: "test",
Reason: "verify official collectible import",
},
SourceGiftID: "6003643167683903930",
Enabled: true,
IncludeCollectible: true,
})
if err != nil {
t.Fatalf("import Party Sparkler: result=%+v err=%v", result, err)
}
if result.Details["models"] != 100 || result.Details["patterns"] != 136 || result.Details["backdrops"] != 60 {
t.Fatalf("Party Sparkler details = %+v", result.Details)
}
catalog, err := giftService.Catalog(ctx)
if err != nil || len(catalog) != 1 {
t.Fatalf("catalog=%+v err=%v, want one gift", catalog, err)
}
preview, ok, err := giftService.CollectiblePreview(ctx, catalog[0].ID)
if err != nil || !ok || len(preview.Models) != 100 || len(preview.Patterns) != 136 || len(preview.Backdrops) != 60 {
t.Fatalf("preview counts=%d/%d/%d ok=%v err=%v", len(preview.Models), len(preview.Patterns), len(preview.Backdrops), ok, err)
}
for _, model := range preview.Models {
if model.Document == nil || !model.Document.IsSticker() || model.Document.IsCustomEmoji() {
t.Fatalf("model %q document=%+v, want ordinary sticker", model.Name, model.Document)
}
}
for _, pattern := range preview.Patterns {
if pattern.Document == nil || pattern.Document.IsSticker() || !pattern.Document.IsCustomEmoji() ||
!hasTextColorCustomEmoji(pattern.Document.Attributes) || !hasInlinePathThumb(pattern.Document.Thumbs) {
t.Fatalf("pattern %q document=%+v, want text-color custom emoji with inline path", pattern.Name, pattern.Document)
}
}
}
func hasTextColorCustomEmoji(attributes []domain.DocumentAttribute) bool {
for _, attribute := range attributes {
if attribute.Kind == domain.DocAttrCustomEmoji && attribute.TextColor {
return true
}
}
return false
}
func hasInlinePathThumb(thumbs []domain.PhotoSize) bool {
for _, thumb := range thumbs {
if thumb.Kind == domain.PhotoSizeKindPath && thumb.Type != "" && len(thumb.Bytes) > 0 {
return true
}
}
return false
}

View file

@ -6,7 +6,6 @@ import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
@ -19,31 +18,31 @@ import (
"time"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
"telesrv/internal/seed/giftdemo"
)
const (
ActionSetAccountFrozen = "account.set_frozen"
ActionGrantPremium = "account.grant_premium"
ActionGrantStars = "account.grant_stars"
ActionSetVerified = "account.set_verified"
ActionSetChannelVerified = "channel.set_verified"
ActionRevokeSessions = "account.revoke_sessions"
ActionDeletePrivateMessages = "messages.delete_private_messages"
ActionDeletePrivateHistory = "messages.delete_private_history"
ActionImportStarGift = "gifts.import"
ActionImportOfficialStarGift = "gifts.official.import"
ActionImportAllOfficialStarGifts = "gifts.official.import_all"
ActionPublishGiftCollectibles = "gifts.collectibles.publish"
ActionSetStarGiftEnabled = "gifts.set_enabled"
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
ActionSetStickerSetArchived = "stickers.set_archived"
ActionSetStickerSetSortOrder = "stickers.set_sort_order"
ActionRenameStickerSet = "stickers.rename"
ActionDeleteStickerSet = "stickers.delete"
ActionCreateStickerSet = "stickers.create"
ActionAddStickerToSet = "stickers.add_sticker"
ActionRemoveStickerFromSet = "stickers.remove_sticker"
ActionSetAccountFrozen = "account.set_frozen"
ActionGrantPremium = "account.grant_premium"
ActionGrantStars = "account.grant_stars"
ActionSetVerified = "account.set_verified"
ActionSetChannelVerified = "channel.set_verified"
ActionRevokeSessions = "account.revoke_sessions"
ActionDeletePrivateMessages = "messages.delete_private_messages"
ActionDeletePrivateHistory = "messages.delete_private_history"
ActionImportStarGift = "gifts.import"
ActionImportDefaultStarGift = "gifts.default.import"
ActionImportAllDefaultStarGifts = "gifts.default.import_all"
ActionPublishGiftCollectibles = "gifts.collectibles.publish"
ActionSetStarGiftEnabled = "gifts.set_enabled"
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
ActionSetStickerSetArchived = "stickers.set_archived"
ActionSetStickerSetSortOrder = "stickers.set_sort_order"
ActionRenameStickerSet = "stickers.rename"
ActionDeleteStickerSet = "stickers.delete"
ActionCreateStickerSet = "stickers.create"
ActionAddStickerToSet = "stickers.add_sticker"
ActionRemoveStickerFromSet = "stickers.remove_sticker"
maxCommandIDLength = 128
maxActorLength = 128
@ -110,7 +109,7 @@ type MessagesService interface {
type GiftsService interface {
PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
Catalog(ctx context.Context) ([]domain.StarGift, error)
CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error)
CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error)
SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error)
@ -121,11 +120,6 @@ type GiftsService interface {
CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
}
type OfficialGiftsSource interface {
List(ctx context.Context) ([]officialgifts.GiftSummary, error)
Bundle(ctx context.Context, giftID int64, includeCollectible bool) (officialgifts.Bundle, error)
}
// AvatarResolver is the same shape as internal/web's ProfilePhotoResolver, kept as its
// own local interface (rather than importing internal/web) since only this narrow slice
// is needed to serve an account's current profile photo in the admin console.
@ -164,7 +158,6 @@ type Dependencies struct {
ChannelNotifier ChannelNotifier
Messages MessagesService
Gifts GiftsService
OfficialGifts OfficialGiftsSource
Photos AvatarResolver
StickerSets StickerSetsService
Now func() time.Time
@ -183,7 +176,6 @@ type Service struct {
channelNotifier ChannelNotifier
messages MessagesService
gifts GiftsService
officialGifts OfficialGiftsSource
photos AvatarResolver
stickerSets StickerSetsService
now func() time.Time
@ -231,9 +223,6 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Gifts != nil {
s.gifts = deps.Gifts
}
if deps.OfficialGifts != nil {
s.officialGifts = deps.OfficialGifts
}
if deps.Photos != nil {
s.photos = deps.Photos
}
@ -282,21 +271,13 @@ type ImportStarGiftRequest struct {
Data []byte `json:"-"`
}
type ImportOfficialStarGiftRequest struct {
type ImportDefaultStarGiftRequest struct {
CommandMeta
ID int `json:"id"`
}
type ImportAllDefaultStarGiftsRequest struct {
CommandMeta
SourceGiftID string `json:"source_gift_id"`
GiftID int64 `json:"gift_id,omitempty"`
Title string `json:"title"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
IncludeCollectible bool `json:"include_collectible"`
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
SupplyTotal int `json:"supply_total,omitempty"`
SlugPrefix string `json:"slug_prefix,omitempty"`
ManifestSHA256 string `json:"manifest_sha256,omitempty"`
AssetSHA256 []string `json:"asset_sha256,omitempty"`
}
type SetStarGiftEnabledRequest struct {
@ -950,11 +931,9 @@ func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest)
})
}
func (s *Service) OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error) {
if s == nil || s.officialGifts == nil {
return nil, officialgifts.ErrUnavailable
}
return s.officialGifts.List(ctx)
// DefaultStarGifts lists the built-in original demo gifts available to import.
func (s *Service) DefaultStarGifts() []giftdemo.GiftInfo {
return giftdemo.List()
}
const maxAccountAvatarBytes = 4 << 20
@ -1057,261 +1036,91 @@ func safeAccountImageType(value string) bool {
}
}
func (s *Service) OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error) {
if s == nil || s.officialGifts == nil || s.gifts == nil {
return nil, false, officialgifts.ErrUnavailable
// DefaultStarGiftAnimation returns the base sticker animation JSON for a
// built-in demo gift, used by the admin preview player.
func (s *Service) DefaultStarGiftAnimation(_ context.Context, id int) ([]byte, bool, error) {
if s == nil || s.gifts == nil {
return nil, false, fmt.Errorf("gift service is not configured")
}
id, err := strconv.ParseInt(strings.TrimSpace(sourceGiftID), 10, 64)
if err != nil || id <= 0 {
return nil, false, officialgifts.ErrNotFound
}
bundle, err := s.officialGifts.Bundle(ctx, id, false)
if errors.Is(err, officialgifts.ErrNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
animation, err := s.gifts.PrepareOfficialAnimation(bundle.BaseDocument.FileName, bundle.BaseDocument.Data)
if err != nil {
return nil, false, err
}
return animation.JSON, true, nil
return giftdemo.BaseAnimationJSON(s.gifts, id)
}
func (s *Service) ImportOfficialStarGift(ctx context.Context, req ImportOfficialStarGiftRequest) (CommandResult, error) {
if s == nil || s.gifts == nil || s.officialGifts == nil {
return CommandResult{}, fmt.Errorf("official star gift importer is not configured")
// ImportDefaultStarGift imports one built-in original demo gift (complete with
// its collectible pool when upgradeable). Idempotent: a gift whose title is
// already in the catalog is skipped rather than duplicated.
func (s *Service) ImportDefaultStarGift(ctx context.Context, req ImportDefaultStarGiftRequest) (CommandResult, error) {
if s == nil || s.gifts == nil {
return CommandResult{}, fmt.Errorf("gift service is not configured")
}
sourceID, err := strconv.ParseInt(strings.TrimSpace(req.SourceGiftID), 10, 64)
if err != nil || sourceID <= 0 || req.GiftID < 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
write, title, err := giftdemo.BuildBundle(s.gifts, req.ID, req.ID)
if err != nil {
return CommandResult{}, domain.ErrStarGiftInvalid
}
bundle, err := s.officialGifts.Bundle(ctx, sourceID, req.IncludeCollectible)
if err != nil {
return CommandResult{}, err
}
if req.Title = strings.TrimSpace(req.Title); req.Title == "" {
req.Title = strings.TrimSpace(bundle.Gift.Title)
if req.Title == "" {
req.Title = "Official gift " + req.SourceGiftID
}
}
if req.Stars <= 0 {
req.Stars = bundle.Gift.Stars
}
if req.ConvertStars < 0 || req.ConvertStars > req.Stars || len([]rune(req.Title)) > domain.MaxStarGiftTitleRunes {
return CommandResult{}, domain.ErrStarGiftInvalid
}
if req.UpgradeStars <= 0 {
req.UpgradeStars = bundle.Gift.UpgradeStars
}
if req.SupplyTotal <= 0 {
req.SupplyTotal = bundle.Gift.AvailabilityTotal
}
if req.SlugPrefix = strings.ToLower(strings.TrimSpace(req.SlugPrefix)); req.SlugPrefix == "" {
req.SlugPrefix = "official-" + req.SourceGiftID
}
baseAnimation, err := s.gifts.PrepareOfficialAnimation(bundle.BaseDocument.FileName, bundle.BaseDocument.Data)
if err != nil {
return CommandResult{}, fmt.Errorf("prepare official gift animation: %w", err)
}
assetHashes := []string{bundle.BaseDocument.SHA256}
rarityCounts := map[string]int{}
var background *domain.StarGiftBackground
if bundle.Gift.Background != nil {
background = &domain.StarGiftBackground{
CenterColor: bundle.Gift.Background.CenterColor,
EdgeColor: bundle.Gift.Background.EdgeColor,
TextColor: bundle.Gift.Background.TextColor,
}
}
var collectible *domain.StarGiftCollectibleWrite
if req.IncludeCollectible {
if bundle.Collectible == nil {
return CommandResult{}, domain.ErrStarGiftCollectibleInvalid
}
modelNames := map[string]int{}
models := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Models))
for index, value := range bundle.Collectible.Models {
animation, err := s.gifts.PrepareOfficialAnimation(value.Document.FileName, value.Document.Data)
if err != nil {
return CommandResult{}, fmt.Errorf("prepare official model %q: %w", value.Name, err)
}
rarityKind, permille, err := officialRarity(value.Rarity)
if err != nil {
return CommandResult{}, err
}
models = append(models, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleModel,
Name: dedupeCollectibleAttributeName(modelNames, strings.TrimSpace(value.Name)), RarityKind: rarityKind, RarityPermille: permille,
Crafted: value.Crafted, OfficialDocumentID: value.DocumentID, SortOrder: index, Animation: &animation})
assetHashes = append(assetHashes, value.Document.SHA256)
rarityCounts[string(rarityKind)]++
}
patternNames := map[string]int{}
patterns := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Patterns))
for index, value := range bundle.Collectible.Patterns {
animation, err := s.gifts.PrepareOfficialAnimation(value.Document.FileName, value.Document.Data)
if err != nil {
return CommandResult{}, fmt.Errorf("prepare official pattern %q: %w", value.Name, err)
}
rarityKind, permille, err := officialRarity(value.Rarity)
if err != nil {
return CommandResult{}, err
}
patterns = append(patterns, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectiblePattern,
Name: dedupeCollectibleAttributeName(patternNames, strings.TrimSpace(value.Name)), RarityKind: rarityKind, RarityPermille: permille,
OfficialDocumentID: value.DocumentID, SortOrder: index, Animation: &animation})
assetHashes = append(assetHashes, value.Document.SHA256)
rarityCounts[string(rarityKind)]++
}
backdropNames := map[string]int{}
backdrops := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Backdrops))
for index, value := range bundle.Collectible.Backdrops {
rarityKind, permille, err := officialRarity(value.Rarity)
if err != nil {
return CommandResult{}, err
}
backdrops = append(backdrops, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop,
Name: dedupeCollectibleAttributeName(backdropNames, strings.TrimSpace(value.Name)), BackdropID: value.BackdropID, CenterColor: value.CenterColor,
EdgeColor: value.EdgeColor, PatternColor: value.PatternColor, TextColor: value.TextColor,
RarityKind: rarityKind, RarityPermille: permille, SortOrder: index})
rarityCounts[string(rarityKind)]++
}
collectible = &domain.StarGiftCollectibleWrite{GiftID: req.GiftID, UpgradeStars: req.UpgradeStars,
SupplyTotal: req.SupplyTotal, SlugPrefix: req.SlugPrefix, Models: models, Patterns: patterns, Backdrops: backdrops,
Actor: req.Actor, CommandID: req.CommandID, OfficialGiftID: sourceID,
SourceManifestSHA256: append([]byte(nil), bundle.ManifestSHA256...)}
validation := *collectible
if validation.GiftID == 0 {
validation.GiftID = 1
}
if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil {
return CommandResult{}, err
}
}
req.ManifestSHA256 = hex.EncodeToString(bundle.ManifestSHA256)
sort.Strings(assetHashes)
req.AssetSHA256 = assetHashes
write := domain.StarGiftCatalogBundleWrite{Catalog: domain.StarGiftCatalogWrite{
GiftID: req.GiftID, Title: req.Title, Stars: req.Stars, ConvertStars: req.ConvertStars,
Enabled: req.Enabled, SortOrder: req.SortOrder, Animation: baseAnimation, Actor: req.Actor, CommandID: req.CommandID,
OfficialGiftID: sourceID, SourceManifestSHA256: append([]byte(nil), bundle.ManifestSHA256...),
OfficialSourceJSON: append([]byte(nil), bundle.SourceJSON...),
// The snapshot describes Telegram's global market, not this deployment's
// inventory. Keep the complete source JSON as provenance, while publishing
// regular official imports as a fresh, locally purchasable catalog entry.
// Local resale counters and sale dates are derived by lifecycle writes.
// Auction gifts are the one exception: star_gift_catalog_revision_auction_check
// requires limited=true whenever auction=true, so it can't be forced false here.
Limited: bundle.Gift.Auction, SoldOut: false, Birthday: bundle.Gift.Birthday,
RequirePremium: bundle.Gift.RequirePremium, LimitedPerUser: bundle.Gift.LimitedPerUser,
PeerColorAvailable: bundle.Gift.PeerColorAvailable, Auction: bundle.Gift.Auction,
AvailabilityRemains: 0, AvailabilityTotal: 0,
AvailabilityResale: 0, FirstSaleDate: 0,
LastSaleDate: 0, ResellMinStars: 0,
PerUserTotal: bundle.Gift.PerUserTotal, LockedUntilDate: bundle.Gift.LockedUntilDate,
AuctionSlug: bundle.Gift.AuctionSlug, GiftsPerRound: bundle.Gift.GiftsPerRound,
AuctionStartDate: bundle.Gift.AuctionStartDate, UpgradeVariants: bundle.Gift.UpgradeVariants,
Background: background,
}, Collectible: collectible}
return s.runCommand(ctx, req.CommandMeta, ActionImportOfficialStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"source_gift_id": req.SourceGiftID, "gift_id": strconv.FormatInt(req.GiftID, 10),
"manifest_sha256": req.ManifestSHA256, "title": req.Title, "stars": strconv.FormatInt(req.Stars, 10),
"convert_stars": strconv.FormatInt(req.ConvertStars, 10), "include_collectible": req.IncludeCollectible,
"verified_asset_count": len(assetHashes), "rarity_counts": rarityCounts,
"official_limited": bundle.Gift.Limited, "official_sold_out": bundle.Gift.SoldOut,
"official_auction": bundle.Gift.Auction, "official_birthday": bundle.Gift.Birthday,
"official_require_premium": bundle.Gift.RequirePremium,
"official_availability_remains": bundle.Gift.AvailabilityRemains,
"official_availability_total": bundle.Gift.AvailabilityTotal,
"official_availability_resale": bundle.Gift.AvailabilityResale,
}
if bundle.Collectible != nil {
details["models"] = len(bundle.Collectible.Models)
details["patterns"] = len(bundle.Collectible.Patterns)
details["backdrops"] = len(bundle.Collectible.Backdrops)
crafted := 0
for _, model := range bundle.Collectible.Models {
if model.Crafted {
crafted++
}
}
details["crafted_models"] = crafted
return s.runCommand(ctx, req.CommandMeta, ActionImportDefaultStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"id": req.ID, "title": title,
"stars": strconv.FormatInt(write.Catalog.Stars, 10),
"upgradeable": write.Collectible != nil,
}
if req.DryRun {
return CommandResult{Message: "official star gift bundle validated", Details: details}, nil
return CommandResult{Message: "default gift validated", Details: details}, nil
}
present, err := giftdemo.PresentTitles(ctx, s.gifts)
if err != nil {
return CommandResult{Details: details}, err
}
if _, ok := present[title]; ok {
details["skipped"] = true
return CommandResult{Message: "default gift already present", Details: details}, nil
}
result, err := s.gifts.CreateCatalogBundle(ctx, write)
if err != nil {
return CommandResult{Details: details}, err
}
details["gift_id"] = strconv.FormatInt(result.Catalog.Gift.ID, 10)
details["catalog_revision_id"] = strconv.FormatInt(result.Catalog.Gift.RevisionID, 10)
if result.Collectible != nil {
details["collectible_revision_id"] = strconv.FormatInt(result.Collectible.ID, 10)
details["collectible_revision"] = result.Collectible.Revision
}
return CommandResult{Message: "official star gift bundle imported", Details: details}, nil
return CommandResult{Message: "default gift imported", Details: details}, nil
})
}
type ImportAllOfficialStarGiftsRequest struct {
CommandMeta
}
// ImportAllOfficialStarGifts imports every gift in the official snapshot, one
// ImportOfficialStarGift call each, all inside the single admin command this
// request itself represents (so it gets the same dry-run/confirm handling
// and audit trail as every other admin action; DryRun previews only report
// the candidate count and do not touch the catalog). Each per-gift call gets
// a CommandID stable across separate confirmed runs of this action
// (bulk-official-gift-<source id>), so re-running the batch later is safe:
// gifts already imported by a prior run replay their cached result
// (CommandResult.AlreadyExecuted) instead of writing a duplicate catalog
// entry — the catalog table has no unique constraint on official_gift_id, so
// without this the same gift could otherwise be imported twice.
func (s *Service) ImportAllOfficialStarGifts(ctx context.Context, req ImportAllOfficialStarGiftsRequest) (CommandResult, error) {
if s == nil || s.gifts == nil || s.officialGifts == nil {
return CommandResult{}, fmt.Errorf("official star gift importer is not configured")
// ImportAllDefaultStarGifts imports every built-in demo gift, one
// ImportDefaultStarGift call each, inside this single admin command. Each
// per-gift call uses a CommandID stable across confirmed runs
// (bulk-default-gift-<id>), so re-running replays cached results instead of
// duplicating catalog entries; already-present gifts are counted as skipped.
func (s *Service) ImportAllDefaultStarGifts(ctx context.Context, req ImportAllDefaultStarGiftsRequest) (CommandResult, error) {
if s == nil || s.gifts == nil {
return CommandResult{}, fmt.Errorf("gift service is not configured")
}
items, err := s.officialGifts.List(ctx)
if err != nil {
return CommandResult{}, err
}
return s.runCommand(ctx, req.CommandMeta, ActionImportAllOfficialStarGifts, 0, domain.Peer{}, req, func() (CommandResult, error) {
items := giftdemo.List()
return s.runCommand(ctx, req.CommandMeta, ActionImportAllDefaultStarGifts, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"total": len(items)}
if req.DryRun {
details["note"] = "dry run does not import; confirming imports all, skipping gifts already imported by a prior run"
return CommandResult{
Message: fmt.Sprintf("%d official gifts available to import", len(items)),
Details: details,
}, nil
details["note"] = "dry run does not import; confirming imports all, skipping gifts already present"
return CommandResult{Message: fmt.Sprintf("%d default gifts available to import", len(items)), Details: details}, nil
}
imported, skipped, failed := 0, 0, 0
perGift := make([]map[string]any, 0, len(items))
for _, item := range items {
perReq := ImportOfficialStarGiftRequest{
perReq := ImportDefaultStarGiftRequest{
CommandMeta: CommandMeta{
CommandID: fmt.Sprintf("bulk-official-gift-%d", item.ID),
CommandID: fmt.Sprintf("bulk-default-gift-%d", item.ID),
Actor: req.Actor,
Reason: req.Reason,
},
SourceGiftID: strconv.FormatInt(item.ID, 10),
// Every attribute the snapshot has for an upgradeable gift is
// worth importing; CanUpgrade() is exactly the precondition
// ImportOfficialStarGift enforces for IncludeCollectible.
IncludeCollectible: item.CanUpgrade(),
ID: item.ID,
}
result, opErr := s.ImportOfficialStarGift(ctx, perReq)
entry := map[string]any{"source_gift_id": perReq.SourceGiftID, "status": result.Status}
if opErr != nil {
result, opErr := s.ImportDefaultStarGift(ctx, perReq)
entry := map[string]any{"id": item.ID, "title": item.Title, "status": result.Status}
switch {
case opErr != nil:
failed++
entry["error"] = opErr.Error()
} else if result.AlreadyExecuted {
case result.AlreadyExecuted, result.Details["skipped"] == true:
skipped++
} else {
default:
imported++
entry["gift_id"] = result.Details["gift_id"]
}
@ -1322,44 +1131,12 @@ func (s *Service) ImportAllOfficialStarGifts(ctx context.Context, req ImportAllO
details["failed"] = failed
details["gifts"] = perGift
return CommandResult{
Message: fmt.Sprintf("imported %d, skipped %d, failed %d of %d official gifts", imported, skipped, failed, len(items)),
Message: fmt.Sprintf("imported %d, skipped %d, failed %d of %d default gifts", imported, skipped, failed, len(items)),
Details: details,
}, nil
})
}
// dedupeCollectibleAttributeName disambiguates attribute names within one kind (models,
// patterns, or backdrops each need distinct names per collectible_revision — see the
// star_gift_collectible_{model,pattern,backdrop}_name_uniq constraints). Official Telegram
// data legitimately reuses a display name across two distinct attributes of the same kind
// (seen in practice: two different "Strawberry" models on one gift), which the DB would
// otherwise reject outright on insert.
func dedupeCollectibleAttributeName(seen map[string]int, name string) string {
key := strings.ToLower(name)
seen[key]++
if seen[key] == 1 {
return name
}
return fmt.Sprintf("%s (%d)", name, seen[key])
}
func officialRarity(value officialgifts.Rarity) (domain.StarGiftAttributeRarityKind, int, error) {
kind := domain.StarGiftAttributeRarityKind(strings.ToLower(strings.TrimSpace(value.Kind)))
if !kind.Valid() {
return "", 0, domain.ErrStarGiftCollectibleInvalid
}
if kind == domain.StarGiftRarityPermille {
if value.Permille == nil || *value.Permille <= 0 || *value.Permille > 1000 {
return "", 0, domain.ErrStarGiftCollectibleInvalid
}
return kind, *value.Permille, nil
}
if value.Permille != nil {
return "", 0, domain.ErrStarGiftCollectibleInvalid
}
return kind, 0, nil
}
func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishStarGiftCollectiblesRequest) (CommandResult, error) {
if s == nil || s.gifts == nil {
return CommandResult{}, fmt.Errorf("star gift service is not configured")

View file

@ -1,7 +1,6 @@
package admin
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@ -13,7 +12,6 @@ import (
stargiftapp "telesrv/internal/app/stargifts"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
"telesrv/internal/store/memory"
)
@ -757,112 +755,72 @@ func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
}
}
func TestImportOfficialStarGiftPreservesCraftedRarityAndPublishesBundle(t *testing.T) {
permille := 922
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
ManifestSHA256: bytesOf(0x42, 32),
SourceJSON: []byte(`{"id":5170145012310081615,"limited":true,"sold_out":true,"availability_total":10,"availability_resale":4}`),
Gift: officialgifts.Gift{
ID: 5170145012310081615, Stars: 50, ConvertStars: 25, UpgradeStars: 100, DocumentID: 1,
Limited: true, SoldOut: true, AvailabilityTotal: 10, AvailabilityRemains: 0,
AvailabilityResale: 4, FirstSaleDate: 100, LastSaleDate: 200, ResellMinStars: 75,
},
BaseDocument: officialgifts.Document{ID: 1, FileName: "gift.tgs", SHA256: strings.Repeat("a", 64), Data: []byte("gift")},
Collectible: &officialgifts.CollectibleSet{
Models: []officialgifts.Model{
{Name: "Regular", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 2, FileName: "regular.tgs", SHA256: strings.Repeat("b", 64), Data: []byte("regular")}},
{Name: "Crafted", DocumentID: 3, Crafted: true, Rarity: officialgifts.Rarity{Kind: "legendary"}, Document: officialgifts.Document{ID: 3, FileName: "crafted.tgs", SHA256: strings.Repeat("c", 64), Data: []byte("crafted")}},
},
Patterns: []officialgifts.Pattern{{Name: "Pattern", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 4, FileName: "pattern.tgs", SHA256: strings.Repeat("d", 64), Data: []byte("pattern")}}},
Backdrops: []officialgifts.Backdrop{{Name: "Black", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}}},
},
}}
gifts := &fakeGiftsService{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, OfficialGifts: source, Now: fixedNow})
req := ImportOfficialStarGiftRequest{SourceGiftID: "5170145012310081615", Enabled: true, IncludeCollectible: true}
req.CommandMeta = CommandMeta{CommandID: "dry-official", Actor: "ops", Reason: "official snapshot", DryRun: true}
preview, err := svc.ImportOfficialStarGift(context.Background(), req)
if err != nil || gifts.createCalls != 0 || preview.Details["crafted_models"] != 1 {
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
}
req.CommandMeta = CommandMeta{CommandID: "exec-official", Actor: "ops", Reason: "official snapshot", DryRun: false}
result, err := svc.ImportOfficialStarGift(context.Background(), req)
if err != nil || gifts.createCalls != 1 || result.Details["collectible_revision_id"] != "33" {
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
}
models := gifts.lastBundle.Collectible.Models
if len(models) != 2 || !models[1].Crafted || models[1].RarityKind != domain.StarGiftRarityLegendary || models[1].RarityPermille != 0 ||
models[0].RarityPermille != 922 || gifts.lastBundle.Collectible.Backdrops[0].BackdropID != 0 {
t.Fatalf("imported models=%+v backdrops=%+v", models, gifts.lastBundle.Collectible.Backdrops)
}
catalog := gifts.lastBundle.Catalog
if catalog.Limited || catalog.SoldOut || catalog.AvailabilityTotal != 0 || catalog.AvailabilityRemains != 0 ||
catalog.AvailabilityResale != 0 || catalog.FirstSaleDate != 0 || catalog.LastSaleDate != 0 || catalog.ResellMinStars != 0 {
t.Fatalf("official global market state leaked into local catalog: %+v", catalog)
}
if catalog.OfficialGiftID != source.bundle.Gift.ID || !bytes.Equal(catalog.OfficialSourceJSON, source.bundle.SourceJSON) ||
!bytes.Equal(catalog.SourceManifestSHA256, source.bundle.ManifestSHA256) {
t.Fatalf("official provenance was not preserved: %+v", catalog)
}
}
func TestImportOfficialStarGiftPublishesThroughRealGiftService(t *testing.T) {
const lottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4}],"assets":[]}`
document := func(id int64, name string) officialgifts.Document {
raw := []byte(lottie)
sum := sha256.Sum256(raw)
return officialgifts.Document{ID: id, FileName: name, SHA256: hex.EncodeToString(sum[:]), Data: raw}
}
permille := 1000
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
ManifestSHA256: bytesOf(0x24, sha256.Size),
SourceJSON: []byte(`{"id":6003643167683903930,"title":"Party Sparkler"}`),
Gift: officialgifts.Gift{
ID: 6003643167683903930, Title: "Party Sparkler", Stars: 15, ConvertStars: 13,
UpgradeStars: 25, AvailabilityTotal: 400000, DocumentID: 1,
},
BaseDocument: document(1, "gift.json"),
Collectible: &officialgifts.CollectibleSet{
Models: []officialgifts.Model{{
Name: "Model", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille},
Document: document(2, "model.json"),
}},
Patterns: []officialgifts.Pattern{{
Name: "Pattern", DocumentID: 3, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille},
Document: document(3, "pattern.json"),
}},
Backdrops: []officialgifts.Backdrop{{
Name: "Backdrop", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille},
}},
},
}}
func TestImportDefaultStarGiftPublishesThroughRealService(t *testing.T) {
ctx := context.Background()
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(), Gifts: giftService, OfficialGifts: source, Now: fixedNow,
})
req := ImportOfficialStarGiftRequest{
SourceGiftID: "6003643167683903930", Enabled: true, IncludeCollectible: true,
CommandMeta: CommandMeta{CommandID: "exec-official-real-service", Actor: "ops", Reason: "regression", DryRun: false},
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: giftService, Now: fixedNow})
if list := svc.DefaultStarGifts(); len(list) != 5 {
t.Fatalf("default gifts = %d, want 5", len(list))
}
result, err := svc.ImportOfficialStarGift(ctx, req)
if err != nil {
t.Fatalf("import official collectible through real service: result=%+v err=%v", result, err)
// Dry-run must not write to the catalog.
dry := ImportDefaultStarGiftRequest{ID: 3, CommandMeta: CommandMeta{CommandID: "dry-default-3", Actor: "ops", Reason: "demo", DryRun: true}}
if _, err := svc.ImportDefaultStarGift(ctx, dry); err != nil {
t.Fatalf("dry run: %v", err)
}
if cat, _ := giftService.Catalog(ctx); len(cat) != 0 {
t.Fatalf("dry run wrote %d gifts", len(cat))
}
// Confirmed import-all creates the whole demo set.
all := ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all", Actor: "ops", Reason: "demo"}}
result, err := svc.ImportAllDefaultStarGifts(ctx, all)
if err != nil || result.Details["imported"] != 5 {
t.Fatalf("import all: result=%+v err=%v", result, err)
}
catalog, err := giftService.Catalog(ctx)
if err != nil || len(catalog) != 1 {
t.Fatalf("catalog=%+v err=%v, want one imported gift", catalog, err)
if err != nil || len(catalog) != 5 {
t.Fatalf("catalog=%d err=%v, want 5", len(catalog), err)
}
preview, ok, err := giftService.CollectiblePreview(ctx, catalog[0].ID)
if err != nil || !ok || len(preview.Models) != 1 || len(preview.Patterns) != 1 {
t.Fatalf("preview=%+v ok=%v err=%v", preview, ok, err)
limited, premium, upgradeable := 0, 0, 0
var craftGiftID int64
for _, g := range catalog {
if g.Limited {
limited++
}
if g.RequirePremium {
premium++
}
if g.UpgradeStars > 0 {
upgradeable++
}
if g.Title == "OwpenGram Coin" {
craftGiftID = g.ID
}
}
model := preview.Models[0].Document
pattern := preview.Patterns[0].Document
if model == nil || !model.IsSticker() || model.IsCustomEmoji() || pattern == nil ||
pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 ||
pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 {
t.Fatalf("materialized model=%+v pattern=%+v", model, pattern)
if limited != 2 || premium != 1 || upgradeable != 4 {
t.Fatalf("stored flags limited=%d premium=%d upgradeable=%d", limited, premium, upgradeable)
}
// The craftable gift must carry a craft-only (named-rarity) model.
preview, ok, err := giftService.CollectiblePreview(ctx, craftGiftID)
if err != nil || !ok {
t.Fatalf("coin preview ok=%v err=%v", ok, err)
}
crafted := 0
for _, m := range preview.Models {
if m.Crafted {
crafted++
}
}
if crafted == 0 {
t.Fatalf("coin has no craft-only model: %+v", preview.Models)
}
// Re-running import-all is idempotent: everything already present -> skipped.
result, err = svc.ImportAllDefaultStarGifts(ctx, ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all-2", Actor: "ops", Reason: "demo"}})
if err != nil || result.Details["imported"] != 0 || result.Details["skipped"] != 5 {
t.Fatalf("re-import: result=%+v err=%v", result, err)
}
}
@ -879,30 +837,6 @@ func (b *adminGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
return append([]byte(nil), b.data[key]...), nil
}
func bytesOf(value byte, count int) []byte {
out := make([]byte, count)
for i := range out {
out[i] = value
}
return out
}
type fakeOfficialGiftsSource struct{ bundle officialgifts.Bundle }
func (f *fakeOfficialGiftsSource) List(context.Context) ([]officialgifts.GiftSummary, error) {
return nil, nil
}
func (f *fakeOfficialGiftsSource) Bundle(_ context.Context, giftID int64, include bool) (officialgifts.Bundle, error) {
if giftID != f.bundle.Gift.ID {
return officialgifts.Bundle{}, officialgifts.ErrNotFound
}
out := f.bundle
if !include {
out.Collectible = nil
}
return out, nil
}
type fakeGiftsService struct {
createCalls int
lastBundle domain.StarGiftCatalogBundleWrite
@ -915,6 +849,10 @@ func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.St
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: sum[:], Width: 512, Height: 512, FrameRate: 30,
}, nil
}
func (f *fakeGiftsService) Catalog(context.Context) ([]domain.StarGift, error) {
return nil, nil
}
func (f *fakeGiftsService) PrepareOfficialAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
return f.PrepareAnimation(name, data)
}

View file

@ -4,7 +4,6 @@ import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@ -16,7 +15,7 @@ import (
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
"telesrv/internal/seed/giftdemo"
)
type Config struct {
@ -35,10 +34,10 @@ type Service interface {
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error)
ImportStarGift(ctx context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error)
ImportOfficialStarGift(ctx context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error)
ImportAllOfficialStarGifts(ctx context.Context, req admin.ImportAllOfficialStarGiftsRequest) (admin.CommandResult, error)
OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error)
OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error)
ImportDefaultStarGift(ctx context.Context, req admin.ImportDefaultStarGiftRequest) (admin.CommandResult, error)
ImportAllDefaultStarGifts(ctx context.Context, req admin.ImportAllDefaultStarGiftsRequest) (admin.CommandResult, error)
DefaultStarGifts() []giftdemo.GiftInfo
DefaultStarGiftAnimation(ctx context.Context, id int) ([]byte, bool, error)
PublishStarGiftCollectibles(ctx context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error)
SetStarGiftEnabled(ctx context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error)
SetStarGiftSortOrder(ctx context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error)
@ -111,10 +110,10 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
mux.HandleFunc("POST /v1/gifts/import", s.authenticated(s.handleImportStarGift))
mux.HandleFunc("GET /v1/official-gifts", s.authenticated(s.handleOfficialStarGifts))
mux.HandleFunc("GET /v1/official-gifts/{id}/animation", s.authenticated(s.handleOfficialStarGiftAnimation))
mux.HandleFunc("POST /v1/official-gifts/import", s.authenticated(s.handleImportOfficialStarGift))
mux.HandleFunc("POST /v1/official-gifts/import-all", s.authenticated(s.handleImportAllOfficialStarGifts))
mux.HandleFunc("GET /v1/default-gifts", s.authenticated(s.handleDefaultStarGifts))
mux.HandleFunc("GET /v1/default-gifts/{id}/animation", s.authenticated(s.handleDefaultStarGiftAnimation))
mux.HandleFunc("POST /v1/default-gifts/import", s.authenticated(s.handleImportDefaultStarGift))
mux.HandleFunc("POST /v1/default-gifts/import-all", s.authenticated(s.handleImportAllDefaultStarGifts))
mux.HandleFunc("POST /v1/gifts/{id}/collectibles/publish", s.authenticated(s.handlePublishStarGiftCollectibles))
mux.HandleFunc("POST /v1/gifts/set-enabled", s.authenticated(s.handleSetStarGiftEnabled))
mux.HandleFunc("POST /v1/gifts/set-sort-order", s.authenticated(s.handleSetStarGiftSortOrder))
@ -271,43 +270,24 @@ func (s *Server) handleImportStarGift(w http.ResponseWriter, r *http.Request) {
writeCommandResult(w, result, err)
}
func (s *Server) handleOfficialStarGifts(w http.ResponseWriter, r *http.Request) {
items, err := s.svc.OfficialStarGifts(r.Context())
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, officialgifts.ErrUnavailable) {
status = http.StatusServiceUnavailable
}
writeError(w, status, err.Error())
func (s *Server) handleDefaultStarGifts(w http.ResponseWriter, r *http.Request) {
items := s.svc.DefaultStarGifts()
writeJSON(w, http.StatusOK, map[string]any{"gifts": items})
}
func (s *Server) handleDefaultStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid gift id")
return
}
result := make([]map[string]any, 0, len(items))
for _, item := range items {
result = append(result, officialStarGiftListItem(item))
}
writeJSON(w, http.StatusOK, map[string]any{"gifts": result})
}
func officialStarGiftListItem(item officialgifts.GiftSummary) map[string]any {
return map[string]any{
"source_gift_id": strconv.FormatInt(item.ID, 10), "title": item.Title,
"stars": strconv.FormatInt(item.Stars, 10), "convert_stars": strconv.FormatInt(item.ConvertStars, 10),
"upgrade_stars": strconv.FormatInt(item.UpgradeStars, 10),
"availability_total": item.AvailabilityTotal, "limited": item.Limited, "sold_out": item.SoldOut,
"model_count": item.ModelCount, "pattern_count": item.PatternCount, "backdrop_count": item.BackdropCount,
"crafted_model_count": item.CraftedModelCount, "can_upgrade": item.CanUpgrade(), "can_craft": item.CanCraft(),
"document_id": strconv.FormatInt(item.DocumentID, 10), "animation_validated": item.AnimationValidated,
}
}
func (s *Server) handleOfficialStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
raw, found, err := s.svc.OfficialStarGiftAnimation(r.Context(), r.PathValue("id"))
raw, found, err := s.svc.DefaultStarGiftAnimation(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeError(w, http.StatusNotFound, "official gift animation not found")
writeError(w, http.StatusNotFound, "default gift animation not found")
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@ -316,21 +296,21 @@ func (s *Server) handleOfficialStarGiftAnimation(w http.ResponseWriter, r *http.
_, _ = w.Write(raw)
}
func (s *Server) handleImportOfficialStarGift(w http.ResponseWriter, r *http.Request) {
var req admin.ImportOfficialStarGiftRequest
func (s *Server) handleImportDefaultStarGift(w http.ResponseWriter, r *http.Request) {
var req admin.ImportDefaultStarGiftRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.ImportOfficialStarGift(r.Context(), req)
result, err := s.svc.ImportDefaultStarGift(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleImportAllOfficialStarGifts(w http.ResponseWriter, r *http.Request) {
var req admin.ImportAllOfficialStarGiftsRequest
func (s *Server) handleImportAllDefaultStarGifts(w http.ResponseWriter, r *http.Request) {
var req admin.ImportAllDefaultStarGiftsRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.ImportAllOfficialStarGifts(r.Context(), req)
result, err := s.svc.ImportAllDefaultStarGifts(r.Context(), req)
writeCommandResult(w, result, err)
}

View file

@ -11,7 +11,7 @@ import (
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
"telesrv/internal/seed/giftdemo"
)
func TestAdminAPIRequiresBearerToken(t *testing.T) {
@ -178,23 +178,6 @@ func TestCollectiblePreviewResponsePreservesInt64AsDecimalStrings(t *testing.T)
}
}
func TestOfficialStarGiftListItemExposesExplicitCapabilities(t *testing.T) {
item := officialStarGiftListItem(officialgifts.GiftSummary{
ID: 9223372036854775807, Title: "Fresh Socks", Stars: 25, ConvertStars: 10, UpgradeStars: 50,
ModelCount: 10, PatternCount: 20, BackdropCount: 30, CraftedModelCount: 2,
})
if item["source_gift_id"] != "9223372036854775807" || item["title"] != "Fresh Socks" ||
item["can_upgrade"] != true || item["can_craft"] != true {
t.Fatalf("official gift item = %#v", item)
}
item = officialStarGiftListItem(officialgifts.GiftSummary{
ID: 1, UpgradeStars: 0, ModelCount: 1, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1,
})
if item["can_upgrade"] != false || item["can_craft"] != false {
t.Fatalf("unavailable official gift capabilities = %#v", item)
}
}
type fakeService struct{}
type captureFreezeService struct {
@ -263,11 +246,11 @@ func (fakeService) ImportStarGift(_ context.Context, req admin.ImportStarGiftReq
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) ImportOfficialStarGift(_ context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error) {
func (fakeService) ImportDefaultStarGift(_ context.Context, req admin.ImportDefaultStarGiftRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) ImportAllOfficialStarGifts(_ context.Context, req admin.ImportAllOfficialStarGiftsRequest) (admin.CommandResult, error) {
func (fakeService) ImportAllDefaultStarGifts(_ context.Context, req admin.ImportAllDefaultStarGiftsRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
@ -307,11 +290,11 @@ func (fakeService) StickerDocumentAnimation(context.Context, int64) ([]byte, str
return nil, "", false, nil
}
func (fakeService) OfficialStarGifts(context.Context) ([]officialgifts.GiftSummary, error) {
return nil, nil
func (fakeService) DefaultStarGifts() []giftdemo.GiftInfo {
return giftdemo.List()
}
func (fakeService) OfficialStarGiftAnimation(context.Context, string) ([]byte, bool, error) {
func (fakeService) DefaultStarGiftAnimation(context.Context, int) ([]byte, bool, error) {
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
}

View file

@ -196,8 +196,6 @@ type Config struct {
WebPagePreviewRatePerMin int
// LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。
LangPackSeedDir string
// OfficialGiftsDir 是 cmd/giftfetch 生成的只读官方礼物快照目录。
OfficialGiftsDir string
// StarGiftTONStartingGrant 是 telesrv 内部 TON 账本首次访问时授予的 nanoton。
// 该账本只用于自建服务端礼物链路,不连接任何外部区块链。
StarGiftTONStartingGrant int64
@ -553,7 +551,6 @@ func Load() (Config, error) {
SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
OfficialGiftsDir: envOr("TELESRV_OFFICIAL_GIFTS_DIR", "data/official-gifts"),
StarGiftTONStartingGrant: envInt64Or("TELESRV_STARGIFT_TON_STARTING_GRANT", 10_000_000_000),
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),

View file

@ -0,0 +1,185 @@
package giftdemo
import "encoding/json"
// This file hand-builds small, fully original 512x512 Lottie animations from
// geometric primitives (polystars, ellipses, rings). They are deliberately
// simple placeholders — the point is a legally-clean demo asset set the admin
// can later replace with real artwork, not studio-grade graphics. Everything
// here is emitted as plain shape/transform JSON, with no expressions and no
// external assets, so it passes the Star Gift animation validator unchanged.
const (
canvasSize = 512
center = canvasSize / 2
frameRate = 60
// 120 frames @ 60fps = 2s loop, well under the 30s ceiling.
outPoint = 120
)
// rgb is a 0..1 normalized colour triplet, the form Lottie fills expect.
type rgb [3]float64
func fromHex(v int) rgb {
return rgb{
float64((v>>16)&0xff) / 255,
float64((v>>8)&0xff) / 255,
float64(v&0xff) / 255,
}
}
type motion int
const (
motionSpin motion = iota
motionPulse
motionSpinPulse
)
// shapeKind picks which primitive the layer draws.
type shapeKind int
const (
shapeStar shapeKind = iota // pointed star (polystar type 1)
shapePolygon
shapeRing // ellipse outline + inner disc, used for "coin"
shapeBurst
)
type lottieSpec struct {
kind shapeKind
points int // star / polygon point count
fill rgb
stroke rgb
strokeW float64 // 0 = no stroke
radius float64
motion motion
}
// prop builds a static Lottie animated-value wrapper {a:0,k:value}.
func prop(value any) map[string]any { return map[string]any{"a": 0, "k": value} }
// easing handles are arrays (never strings), so the validator's expression
// check — which only rejects string-valued "x" keys — never trips on them.
func keyframe(t float64, value []float64) map[string]any {
return map[string]any{
"t": t,
"s": value,
"i": map[string]any{"x": []float64{0.6}, "y": []float64{1}},
"o": map[string]any{"x": []float64{0.4}, "y": []float64{0}},
}
}
func spinRotation() map[string]any {
return map[string]any{"a": 1, "k": []any{
keyframe(0, []float64{0}),
keyframe(outPoint, []float64{360}),
}}
}
func pulseScale() map[string]any {
return map[string]any{"a": 1, "k": []any{
keyframe(0, []float64{100, 100, 100}),
keyframe(outPoint/2, []float64{114, 114, 114}),
keyframe(outPoint, []float64{100, 100, 100}),
}}
}
func transform(m motion) map[string]any {
rotation := prop(0.0)
scale := prop([]float64{100, 100, 100})
switch m {
case motionSpin:
rotation = spinRotation()
case motionPulse:
scale = pulseScale()
case motionSpinPulse:
rotation = spinRotation()
scale = pulseScale()
}
return map[string]any{
"o": prop(100.0),
"r": rotation,
"p": prop([]float64{center, center, 0}),
"a": prop([]float64{0, 0, 0}),
"s": scale,
"sk": prop(0.0),
"sa": prop(0.0),
}
}
func fill(c rgb) map[string]any {
return map[string]any{
"ty": "fl", "nm": "Fill", "r": 1,
"o": prop(100.0),
"c": prop([]float64{c[0], c[1], c[2]}),
}
}
func stroke(c rgb, width float64) map[string]any {
return map[string]any{
"ty": "st", "nm": "Stroke", "lc": 2, "lj": 2, "ml": 4,
"o": prop(100.0),
"w": prop(width),
"c": prop([]float64{c[0], c[1], c[2]}),
}
}
func polystar(points int, starType int, outer, innerRatio float64) map[string]any {
return map[string]any{
"ty": "sr", "nm": "Polystar", "sy": starType,
"d": 1,
"pt": prop(float64(points)),
"p": prop([]float64{0, 0}),
"r": prop(0.0),
"ir": prop(outer * innerRatio),
"is": prop(0.0),
"or": prop(outer),
"os": prop(0.0),
}
}
func ellipse(radius float64) map[string]any {
return map[string]any{
"ty": "el", "nm": "Ellipse", "d": 1,
"p": prop([]float64{0, 0}),
"s": prop([]float64{radius * 2, radius * 2}),
}
}
// renderLottie serializes one spec to Lottie JSON bytes.
func renderLottie(spec lottieSpec) ([]byte, error) {
var shapes []any
switch spec.kind {
case shapeStar:
shapes = append(shapes, polystar(spec.points, 1, spec.radius, 0.5))
case shapePolygon:
shapes = append(shapes, polystar(spec.points, 2, spec.radius, 0.5))
case shapeBurst:
shapes = append(shapes, polystar(spec.points, 1, spec.radius, 0.32))
case shapeRing:
shapes = append(shapes, ellipse(spec.radius))
}
shapes = append(shapes, fill(spec.fill))
if spec.strokeW > 0 {
shapes = append(shapes, stroke(spec.stroke, spec.strokeW))
}
// A coin gets a smaller contrasting inner disc for a bit of depth.
if spec.kind == shapeRing {
shapes = append(shapes, ellipse(spec.radius*0.55), fill(spec.stroke))
}
layer := map[string]any{
"ddd": 0, "ind": 1, "ty": 4, "nm": "gift", "sr": 1,
"ks": transform(spec.motion),
"ao": 0,
"shapes": shapes,
"ip": 0, "op": outPoint, "st": 0, "bm": 0,
}
root := map[string]any{
"v": "5.7.4", "fr": frameRate, "ip": 0, "op": outPoint,
"w": canvasSize, "h": canvasSize, "nm": "owpengram-demo-gift",
"ddd": 0, "assets": []any{}, "layers": []any{layer},
}
return json.Marshal(root)
}

View file

@ -0,0 +1,259 @@
// Package giftdemo is an in-memory catalog of small, fully original demo Star
// Gifts (geometric Lottie authored here — not Telegram's copyrighted assets).
// It backs the admin console's "Default gifts" import source: operators can
// import these to demo the complete gift surface (upgrade + craft) without any
// third-party artwork. Nothing here is enabled automatically; gifts appear in
// the catalog only once an operator imports them.
package giftdemo
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// Preparer normalizes raw Lottie bytes into the canonical Star Gift animation
// pair. *stargifts.Service satisfies it.
type Preparer interface {
PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
}
const (
seedActor = "system-default-gifts"
seedCommandID = "default-gifts-v1"
)
type attrSpec struct {
name string
spec lottieSpec
permille int // >0 for a normal (drawable) attribute
crafted bool // craft-only model; permille must be 0 and rarity named
rarity domain.StarGiftAttributeRarityKind
}
type backdropSpec struct {
name string
center int
edge int
pattern int
text int
permille int
}
type upgradeSpec struct {
upgradeStars int64
supplyTotal int
slug string
models []attrSpec
patterns []attrSpec
backdrops []backdropSpec
}
type giftSpec struct {
title string
stars int64
convert int64
base lottieSpec
limited bool
availability int
requirePremium bool
birthday bool
upgrade *upgradeSpec
}
// GiftInfo is a catalog listing entry for the import picker.
type GiftInfo struct {
ID int `json:"id"`
Title string `json:"title"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars"`
UpgradeStars int64 `json:"upgrade_stars"`
Upgradeable bool `json:"upgradeable"`
Craftable bool `json:"craftable"`
Limited bool `json:"limited"`
Availability int `json:"availability"`
RequirePremium bool `json:"require_premium"`
ModelCount int `json:"model_count"`
PatternCount int `json:"pattern_count"`
BackdropCount int `json:"backdrop_count"`
CraftedCount int `json:"crafted_count"`
}
// List returns the built-in demo gifts, ids 1..N in display order.
func List() []GiftInfo {
gifts := demoGifts()
out := make([]GiftInfo, 0, len(gifts))
for i, spec := range gifts {
info := GiftInfo{
ID: i + 1, Title: spec.title, Stars: spec.stars, ConvertStars: spec.convert,
Limited: spec.limited, Availability: spec.availability, RequirePremium: spec.requirePremium,
}
if spec.upgrade != nil {
info.Upgradeable = true
info.UpgradeStars = spec.upgrade.upgradeStars
info.ModelCount = len(spec.upgrade.models)
info.PatternCount = len(spec.upgrade.patterns)
info.BackdropCount = len(spec.upgrade.backdrops)
for _, m := range spec.upgrade.models {
if m.crafted {
info.CraftedCount++
}
}
info.Craftable = info.CraftedCount > 0
}
out = append(out, info)
}
return out
}
func specByID(id int) (giftSpec, int, bool) {
gifts := demoGifts()
if id < 1 || id > len(gifts) {
return giftSpec{}, 0, false
}
return gifts[id-1], id - 1, true
}
// BaseAnimationJSON returns the normalized Lottie JSON of a gift's base sticker,
// used by the admin preview player.
func BaseAnimationJSON(prep Preparer, id int) ([]byte, bool, error) {
spec, _, ok := specByID(id)
if !ok {
return nil, false, nil
}
anim, err := prepare(prep, spec.title, spec.base)
if err != nil {
return nil, false, err
}
return anim.JSON, true, nil
}
// BuildBundle assembles the complete catalog+collectible write for one demo
// gift. The bundle carries no official provenance (OfficialGiftID stays 0), so
// these import as ordinary locally-authored catalog entries.
func BuildBundle(prep Preparer, id, sortOrder int) (domain.StarGiftCatalogBundleWrite, string, error) {
spec, _, ok := specByID(id)
if !ok {
return domain.StarGiftCatalogBundleWrite{}, "", fmt.Errorf("unknown demo gift id %d", id)
}
write, err := buildBundle(prep, spec, sortOrder)
if err != nil {
return domain.StarGiftCatalogBundleWrite{}, "", err
}
return write, spec.title, nil
}
// CatalogReader is the read side used to skip gifts already present by title.
type CatalogReader interface {
Catalog(ctx context.Context) ([]domain.StarGift, error)
}
// PresentTitles returns the set of demo gift titles already in the catalog, so
// callers can import idempotently.
func PresentTitles(ctx context.Context, reader CatalogReader) (map[string]struct{}, error) {
existing, err := reader.Catalog(ctx)
if err != nil {
return nil, err
}
demo := map[string]struct{}{}
for _, spec := range demoGifts() {
demo[spec.title] = struct{}{}
}
present := map[string]struct{}{}
for _, gift := range existing {
if _, ok := demo[gift.Title]; ok {
present[gift.Title] = struct{}{}
}
}
return present, nil
}
func buildBundle(prep Preparer, spec giftSpec, sortOrder int) (domain.StarGiftCatalogBundleWrite, error) {
baseAnim, err := prepare(prep, spec.title, spec.base)
if err != nil {
return domain.StarGiftCatalogBundleWrite{}, err
}
catalog := domain.StarGiftCatalogWrite{
Title: spec.title,
Stars: spec.stars,
ConvertStars: spec.convert,
Enabled: true,
SortOrder: sortOrder,
Animation: baseAnim,
Actor: seedActor,
CommandID: seedCommandID,
}
if spec.limited {
catalog.Limited = true
catalog.AvailabilityTotal = spec.availability
catalog.AvailabilityRemains = spec.availability
}
catalog.RequirePremium = spec.requirePremium
catalog.Birthday = spec.birthday
write := domain.StarGiftCatalogBundleWrite{Catalog: catalog}
if spec.upgrade == nil {
return write, nil
}
up := spec.upgrade
models, err := buildAttributes(prep, up.models, domain.StarGiftCollectibleModel)
if err != nil {
return domain.StarGiftCatalogBundleWrite{}, err
}
patterns, err := buildAttributes(prep, up.patterns, domain.StarGiftCollectiblePattern)
if err != nil {
return domain.StarGiftCatalogBundleWrite{}, err
}
backdrops := make([]domain.StarGiftCollectibleAttribute, 0, len(up.backdrops))
for i, b := range up.backdrops {
backdrops = append(backdrops, domain.StarGiftCollectibleAttribute{
Kind: domain.StarGiftCollectibleBackdrop, Name: b.name, BackdropID: i + 1,
CenterColor: b.center, EdgeColor: b.edge, PatternColor: b.pattern, TextColor: b.text,
RarityKind: domain.StarGiftRarityPermille, RarityPermille: b.permille, SortOrder: i,
})
}
write.Collectible = &domain.StarGiftCollectibleWrite{
UpgradeStars: up.upgradeStars,
SupplyTotal: up.supplyTotal,
SlugPrefix: up.slug,
Models: models,
Patterns: patterns,
Backdrops: backdrops,
Actor: seedActor,
CommandID: seedCommandID,
}
return write, nil
}
func buildAttributes(prep Preparer, specs []attrSpec, kind domain.StarGiftCollectibleAttributeKind) ([]domain.StarGiftCollectibleAttribute, error) {
out := make([]domain.StarGiftCollectibleAttribute, 0, len(specs))
for i, s := range specs {
anim, err := prepare(prep, s.name, s.spec)
if err != nil {
return nil, err
}
attr := domain.StarGiftCollectibleAttribute{
Kind: kind, Name: s.name, SortOrder: i, Animation: &anim,
}
if s.crafted {
attr.Crafted = true
attr.RarityKind = s.rarity
attr.RarityPermille = 0
} else {
attr.RarityKind = domain.StarGiftRarityPermille
attr.RarityPermille = s.permille
}
out = append(out, attr)
}
return out, nil
}
func prepare(prep Preparer, name string, spec lottieSpec) (domain.StarGiftAnimation, error) {
data, err := renderLottie(spec)
if err != nil {
return domain.StarGiftAnimation{}, err
}
return prep.PrepareAnimation(name+".json", data)
}

View file

@ -0,0 +1,133 @@
package giftdemo
import (
"context"
"crypto/sha256"
"encoding/hex"
"testing"
"telesrv/internal/app/stargifts"
"telesrv/internal/store/memory"
)
type fakeBlob struct{ data map[string][]byte }
func (b *fakeBlob) Name() string { return "localfs" }
func (b *fakeBlob) Put(_ context.Context, data []byte) (string, error) {
sum := sha256.Sum256(data)
key := hex.EncodeToString(sum[:])
b.data[key] = append([]byte(nil), data...)
return key, nil
}
func (b *fakeBlob) Get(_ context.Context, key string) ([]byte, error) {
return append([]byte(nil), b.data[key]...), nil
}
func newService() *stargifts.Service {
return stargifts.NewService(memory.NewStarGiftStore(), &fakeBlob{data: map[string][]byte{}}, 2)
}
func TestListDescribesFullGiftSurface(t *testing.T) {
list := List()
if len(list) != 5 {
t.Fatalf("List has %d gifts, want 5", len(list))
}
upgradeable, craftable, limited, premium := 0, 0, 0, 0
for _, g := range list {
if g.ID < 1 || g.Title == "" || g.Stars <= 0 {
t.Fatalf("bad gift info: %+v", g)
}
if g.Upgradeable {
upgradeable++
}
if g.Craftable {
craftable++
}
if g.Limited {
limited++
}
if g.RequirePremium {
premium++
}
}
if upgradeable != 4 || craftable != 3 || limited != 2 || premium != 1 {
t.Fatalf("surface counts upgradeable=%d craftable=%d limited=%d premium=%d", upgradeable, craftable, limited, premium)
}
}
// Every demo gift must build into a valid catalog+collectible bundle and
// import cleanly through the real service (which materializes and validates
// the whole pool). Limited/premium flags must survive onto the stored gift.
func TestBuildBundleImportsEndToEnd(t *testing.T) {
ctx := context.Background()
svc := newService()
for _, info := range List() {
write, title, err := BuildBundle(svc, info.ID, info.ID)
if err != nil {
t.Fatalf("build %q: %v", info.Title, err)
}
if title != info.Title {
t.Fatalf("title mismatch %q != %q", title, info.Title)
}
if _, err := svc.CreateCatalogBundle(ctx, write); err != nil {
t.Fatalf("import %q: %v", info.Title, err)
}
}
catalog, err := svc.Catalog(ctx)
if err != nil {
t.Fatal(err)
}
if len(catalog) != 5 {
t.Fatalf("catalog has %d, want 5", len(catalog))
}
limited, premium, upgradeable := 0, 0, 0
for _, g := range catalog {
if g.Limited {
limited++
}
if g.RequirePremium {
premium++
}
if g.UpgradeStars > 0 {
upgradeable++
}
}
if limited != 2 || premium != 1 || upgradeable != 4 {
t.Fatalf("stored flags limited=%d premium=%d upgradeable=%d", limited, premium, upgradeable)
}
}
func TestBaseAnimationJSONRenders(t *testing.T) {
svc := newService()
data, ok, err := BaseAnimationJSON(svc, 1)
if err != nil || !ok || len(data) == 0 {
t.Fatalf("base animation id=1: ok=%v err=%v len=%d", ok, err, len(data))
}
if _, ok, _ := BaseAnimationJSON(svc, 99); ok {
t.Fatalf("id=99 should not exist")
}
}
func TestPresentTitles(t *testing.T) {
ctx := context.Background()
svc := newService()
write, _, err := BuildBundle(svc, 1, 0)
if err != nil {
t.Fatal(err)
}
if _, err := svc.CreateCatalogBundle(ctx, write); err != nil {
t.Fatal(err)
}
present, err := PresentTitles(ctx, svc)
if err != nil {
t.Fatal(err)
}
if len(present) != 1 {
t.Fatalf("present=%v, want exactly the one imported title", present)
}
if _, ok := present["OwpenGram Spark"]; !ok {
t.Fatalf("expected Spark present, got %v", present)
}
}

View file

@ -0,0 +1,150 @@
package giftdemo
import "telesrv/internal/domain"
// Palette (hex) shared across the demo assets.
const (
colGold = 0xF5C542
colAmber = 0xF59E0B
colBlue = 0x2563EB
colCyan = 0x38BDF8
colViolet = 0x7C3AED
colEmerald = 0x10B981
colRose = 0xF43F5E
colWhite = 0xFFFFFF
colSlate = 0x1E293B
colMidnight = 0x0B1220
colDeepEm = 0x065F46
)
func star(points int, fill, stroke int, strokeW float64, m motion) lottieSpec {
return lottieSpec{kind: shapeStar, points: points, fill: fromHex(fill), stroke: fromHex(stroke), strokeW: strokeW, radius: 168, motion: m}
}
func polygon(points, fill, stroke int, strokeW float64, m motion) lottieSpec {
return lottieSpec{kind: shapePolygon, points: points, fill: fromHex(fill), stroke: fromHex(stroke), strokeW: strokeW, radius: 150, motion: m}
}
func ring(fill, inner int, m motion) lottieSpec {
return lottieSpec{kind: shapeRing, fill: fromHex(fill), stroke: fromHex(inner), radius: 150, motion: m}
}
func burst(points, fill int, m motion) lottieSpec {
return lottieSpec{kind: shapeBurst, points: points, fill: fromHex(fill), radius: 150, motion: m}
}
// Four colour-only backdrops reused across every upgradeable gift (backdrops
// carry no animation asset, so sharing them is free).
func demoBackdrops() []backdropSpec {
return []backdropSpec{
{name: "Midnight", center: colSlate, edge: colMidnight, pattern: colCyan, text: colWhite, permille: 400},
{name: "Sunset", center: colAmber, edge: colRose, pattern: colWhite, text: colSlate, permille: 300},
{name: "Emerald", center: colEmerald, edge: colDeepEm, pattern: colWhite, text: colWhite, permille: 200},
{name: "Royal", center: colViolet, edge: colBlue, pattern: colGold, text: colWhite, permille: 100},
}
}
// demoGifts returns the five demo gifts in display order.
func demoGifts() []giftSpec {
return []giftSpec{
{
// #1 — cheapest, plain, not upgradeable.
title: "OwpenGram Spark",
stars: 15,
convert: 15,
base: burst(8, colGold, motionPulse),
},
{
// #2 — standard upgradeable, no crafting.
title: "OwpenGram Star",
stars: 50,
convert: 50,
base: star(5, colBlue, colCyan, 10, motionSpin),
upgrade: &upgradeSpec{
upgradeStars: 200,
supplyTotal: 10000,
slug: "owg-star",
models: []attrSpec{
{name: "Sapphire", spec: star(5, colBlue, colCyan, 12, motionSpin), permille: 600},
{name: "Frost", spec: star(6, colCyan, colWhite, 10, motionSpin), permille: 400},
},
patterns: []attrSpec{
{name: "Halo", spec: burst(12, colCyan, motionPulse), permille: 700},
{name: "Drift", spec: burst(8, colBlue, motionPulse), permille: 300},
},
backdrops: demoBackdrops(),
},
},
{
// #3 — upgradeable + craftable.
title: "OwpenGram Coin",
stars: 100,
convert: 75,
base: ring(colAmber, colSlate, motionSpin),
upgrade: &upgradeSpec{
upgradeStars: 400,
supplyTotal: 8000,
slug: "owg-coin",
models: []attrSpec{
{name: "Bronze", spec: ring(colAmber, colSlate, motionSpin), permille: 600},
{name: "Silver", spec: ring(colWhite, colSlate, motionSpin), permille: 400},
{name: "Molten", spec: ring(colRose, colAmber, motionSpinPulse), crafted: true, rarity: domain.StarGiftRarityRare},
},
patterns: []attrSpec{
{name: "Gleam", spec: burst(10, colGold, motionPulse), permille: 700},
{name: "Ember", spec: burst(6, colAmber, motionPulse), permille: 300},
},
backdrops: demoBackdrops(),
},
},
{
// #4 — limited edition, upgradeable + craftable.
title: "OwpenGram Gem",
stars: 250,
convert: 200,
base: polygon(6, colViolet, colWhite, 10, motionSpinPulse),
limited: true,
availability: 5000,
upgrade: &upgradeSpec{
upgradeStars: 800,
supplyTotal: 3000,
slug: "owg-gem",
models: []attrSpec{
{name: "Amethyst", spec: polygon(6, colViolet, colWhite, 12, motionSpin), permille: 600},
{name: "Verdant", spec: polygon(6, colEmerald, colWhite, 12, motionSpin), permille: 400},
{name: "Prism", spec: polygon(8, colCyan, colWhite, 10, motionSpinPulse), crafted: true, rarity: domain.StarGiftRarityEpic},
},
patterns: []attrSpec{
{name: "Facet", spec: burst(12, colViolet, motionPulse), permille: 700},
{name: "Shine", spec: burst(8, colWhite, motionPulse), permille: 300},
},
backdrops: demoBackdrops(),
},
},
{
// #5 — premium-gated, limited, the full stack.
title: "OwpenGram Crown",
stars: 1000,
convert: 800,
base: star(3, colGold, colAmber, 12, motionSpinPulse),
limited: true,
availability: 500,
requirePremium: true,
upgrade: &upgradeSpec{
upgradeStars: 2000,
supplyTotal: 500,
slug: "owg-crown",
models: []attrSpec{
{name: "Regal", spec: star(3, colGold, colAmber, 14, motionSpinPulse), permille: 600},
{name: "Noble", spec: star(5, colAmber, colGold, 12, motionSpin), permille: 400},
{name: "Eternal", spec: star(6, colGold, colWhite, 12, motionSpinPulse), crafted: true, rarity: domain.StarGiftRarityLegendary},
},
patterns: []attrSpec{
{name: "Aura", spec: burst(12, colGold, motionPulse), permille: 700},
{name: "Crest", spec: burst(8, colWhite, motionPulse), permille: 300},
},
backdrops: demoBackdrops(),
},
},
}
}