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

@ -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(),
},
},
}
}