From 14bf7d1e204398f350c70602de175cc565262ba1 Mon Sep 17 00:00:00 2001 From: A Date: Sun, 19 Jul 2026 03:11:35 +0800 Subject: [PATCH] feat: sync official star gift lifecycle tooling Sync telesrv 4c0e2d9 (feat: complete official star gift lifecycle and admin tooling). Public adjustments: skipped private docs/deploy-nginx/README source changes, kept iamxvbaba/td public dependency, replaced local/private sample IP and orange seed label. --- .env.example | 17 + cmd/giftfetch/main.go | 1042 ++++++++++ cmd/giftfetch/main_test.go | 238 +++ cmd/telesrv-admin/readstore.go | 14 +- cmd/telesrv-admin/server.go | 65 +- cmd/telesrv-admin/session_test.go | 73 + .../web/dist/assets/index-BFkUM6v2.js | 9 - .../web/dist/assets/index-BaxMq_AT.css | 1 - .../web/dist/assets/index-DHdrFM5j.css | 1 + .../web/dist/assets/index-DKmJO2ZY.js | 9 + cmd/telesrv-admin/web/dist/index.html | 26 +- cmd/telesrv-admin/web/src/api.ts | 12 +- cmd/telesrv-admin/web/src/i18n.tsx | 66 +- .../web/src/pages/GiftCollectiblesModal.tsx | 15 +- cmd/telesrv-admin/web/src/pages/GiftsPage.tsx | 191 +- .../src/styles/03-entities-and-actions.css | 59 + cmd/telesrv-admin/web/src/types.ts | 44 +- cmd/telesrv/main.go | 77 +- ...093_official_star_gift_attributes.down.sql | 72 + .../0093_official_star_gift_attributes.up.sql | 88 + .../0094_star_gift_catalog_shape.down.sql | 34 + .../0094_star_gift_catalog_shape.up.sql | 68 + .../0095_star_gift_lifecycle.down.sql | 76 + .../0095_star_gift_lifecycle.up.sql | 425 +++++ .../0096_star_gift_lifecycle_sweeper.down.sql | 6 + .../0096_star_gift_lifecycle_sweeper.up.sql | 16 + .../0097_star_gift_peer_stars_ledger.down.sql | 3 + .../0097_star_gift_peer_stars_ledger.up.sql | 40 + ...0098_star_gift_channel_ton_ledger.down.sql | 2 + .../0098_star_gift_channel_ton_ledger.up.sql | 25 + .../0099_star_gift_signed_form_ids.down.sql | 20 + .../0099_star_gift_signed_form_ids.up.sql | 20 + .../0100_star_gift_upgrade_semantics.down.sql | 35 + .../0100_star_gift_upgrade_semantics.up.sql | 79 + ...ar_gift_upgrade_projection_repair.down.sql | 3 + ...star_gift_upgrade_projection_repair.up.sql | 74 + .../0102_star_gift_purchase_forms.down.sql | 1 + .../0102_star_gift_purchase_forms.up.sql | 23 + ...3_star_gift_upgrade_message_links.down.sql | 5 + ...103_star_gift_upgrade_message_links.up.sql | 191 ++ .../0104_star_gift_craft_capability.down.sql | 4 + .../0104_star_gift_craft_capability.up.sql | 109 ++ .../0105_star_gift_craft_projection.down.sql | 7 + .../0105_star_gift_craft_projection.up.sql | 195 ++ internal/admin/service.go | 280 ++- internal/admin/service_test.go | 98 +- internal/adminapi/server.go | 75 +- internal/adminapi/server_test.go | 56 + internal/app/stargifts/animation.go | 17 +- internal/app/stargifts/animation_test.go | 23 + internal/app/stargifts/local_withdrawal.go | 50 + .../app/stargifts/local_withdrawal_test.go | 34 + .../app/stargifts/official_snapshot_test.go | 56 + internal/app/stargifts/service.go | 574 +++++- internal/config/config.go | 77 +- internal/config/config_test.go | 13 + internal/domain/channel.go | 6 +- internal/domain/media.go | 114 +- internal/domain/star_gift.go | 681 ++++++- internal/domain/star_gift_collectible_test.go | 95 + internal/domain/stars.go | 42 +- internal/officialgifts/catalog.go | 531 ++++++ internal/officialgifts/catalog_test.go | 119 ++ internal/rpc/convert_channels_core.go | 2 + internal/rpc/convert_messages.go | 68 +- internal/rpc/deps.go | 26 +- internal/rpc/errors.go | 4 + internal/rpc/payments.go | 358 +++- ...ments_star_gift_catalog_projection_test.go | 98 + internal/rpc/payments_star_gift_lifecycle.go | 1019 ++++++++++ internal/rpc/payments_star_gift_unique.go | 234 ++- internal/rpc/payments_star_gifts.go | 382 ++-- internal/rpc/payments_star_gifts_rpc_test.go | 391 +++- internal/rpc/payments_stars_rpc_test.go | 110 ++ internal/store/memory/star_gift.go | 89 +- .../store/memory/star_gift_identity_test.go | 41 + internal/store/postgres/channel_groupcall.go | 47 +- internal/store/postgres/star_gift.go | 274 ++- .../store/postgres/star_gift_collectibles.go | 154 +- ...star_gift_collectibles_integration_test.go | 278 ++- .../store/postgres/star_gift_craft_auction.go | 1094 +++++++++++ .../postgres/star_gift_craft_projection.go | 227 +++ .../store/postgres/star_gift_entitlements.go | 282 +++ .../store/postgres/star_gift_lifecycle.go | 1693 +++++++++++++++++ .../star_gift_lifecycle_integration_test.go | 827 ++++++++ ...ft_lifecycle_migration_integration_test.go | 20 + ...r_gift_official_import_integration_test.go | 94 + internal/store/postgres/star_gift_purchase.go | 344 ++++ internal/store/postgres/star_gift_upgrade.go | 402 +++- internal/store/star_gift.go | 43 + internal/web/server.go | 214 ++- internal/web/server_test.go | 159 +- 92 files changed, 14768 insertions(+), 727 deletions(-) create mode 100644 cmd/giftfetch/main.go create mode 100644 cmd/giftfetch/main_test.go delete mode 100644 cmd/telesrv-admin/web/dist/assets/index-BFkUM6v2.js delete mode 100644 cmd/telesrv-admin/web/dist/assets/index-BaxMq_AT.css create mode 100644 cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css create mode 100644 cmd/telesrv-admin/web/dist/assets/index-DKmJO2ZY.js create mode 100644 deploy/migrations/0093_official_star_gift_attributes.down.sql create mode 100644 deploy/migrations/0093_official_star_gift_attributes.up.sql create mode 100644 deploy/migrations/0094_star_gift_catalog_shape.down.sql create mode 100644 deploy/migrations/0094_star_gift_catalog_shape.up.sql create mode 100644 deploy/migrations/0095_star_gift_lifecycle.down.sql create mode 100644 deploy/migrations/0095_star_gift_lifecycle.up.sql create mode 100644 deploy/migrations/0096_star_gift_lifecycle_sweeper.down.sql create mode 100644 deploy/migrations/0096_star_gift_lifecycle_sweeper.up.sql create mode 100644 deploy/migrations/0097_star_gift_peer_stars_ledger.down.sql create mode 100644 deploy/migrations/0097_star_gift_peer_stars_ledger.up.sql create mode 100644 deploy/migrations/0098_star_gift_channel_ton_ledger.down.sql create mode 100644 deploy/migrations/0098_star_gift_channel_ton_ledger.up.sql create mode 100644 deploy/migrations/0099_star_gift_signed_form_ids.down.sql create mode 100644 deploy/migrations/0099_star_gift_signed_form_ids.up.sql create mode 100644 deploy/migrations/0100_star_gift_upgrade_semantics.down.sql create mode 100644 deploy/migrations/0100_star_gift_upgrade_semantics.up.sql create mode 100644 deploy/migrations/0101_star_gift_upgrade_projection_repair.down.sql create mode 100644 deploy/migrations/0101_star_gift_upgrade_projection_repair.up.sql create mode 100644 deploy/migrations/0102_star_gift_purchase_forms.down.sql create mode 100644 deploy/migrations/0102_star_gift_purchase_forms.up.sql create mode 100644 deploy/migrations/0103_star_gift_upgrade_message_links.down.sql create mode 100644 deploy/migrations/0103_star_gift_upgrade_message_links.up.sql create mode 100644 deploy/migrations/0104_star_gift_craft_capability.down.sql create mode 100644 deploy/migrations/0104_star_gift_craft_capability.up.sql create mode 100644 deploy/migrations/0105_star_gift_craft_projection.down.sql create mode 100644 deploy/migrations/0105_star_gift_craft_projection.up.sql create mode 100644 internal/app/stargifts/local_withdrawal.go create mode 100644 internal/app/stargifts/local_withdrawal_test.go create mode 100644 internal/app/stargifts/official_snapshot_test.go create mode 100644 internal/domain/star_gift_collectible_test.go create mode 100644 internal/officialgifts/catalog.go create mode 100644 internal/officialgifts/catalog_test.go create mode 100644 internal/rpc/payments_star_gift_catalog_projection_test.go create mode 100644 internal/rpc/payments_star_gift_lifecycle.go create mode 100644 internal/store/memory/star_gift_identity_test.go create mode 100644 internal/store/postgres/star_gift_craft_auction.go create mode 100644 internal/store/postgres/star_gift_craft_projection.go create mode 100644 internal/store/postgres/star_gift_entitlements.go create mode 100644 internal/store/postgres/star_gift_lifecycle.go create mode 100644 internal/store/postgres/star_gift_lifecycle_integration_test.go create mode 100644 internal/store/postgres/star_gift_lifecycle_migration_integration_test.go create mode 100644 internal/store/postgres/star_gift_official_import_integration_test.go create mode 100644 internal/store/postgres/star_gift_purchase.go diff --git a/.env.example b/.env.example index 12d4e047..4d388357 100644 --- a/.env.example +++ b/.env.example @@ -132,6 +132,23 @@ TELESRV_MAPBOX_TOKEN= TELESRV_MAPTILE_CACHE_DIR=data/maptiles TELESRV_LANGPACK_SEED_DIR=data/langpack +TELESRV_OFFICIAL_GIFTS_DIR=data/official-gifts +# Star Gift expiry/auction worker. TON values are handled by the local ledger; +# no wallet, Fragment or chain node endpoint is configured or contacted. +TELESRV_STARGIFT_SWEEP_INTERVAL=15s +TELESRV_STARGIFT_SWEEP_BATCH=1000 +# Internal nanoton granted once per user on first local-ledger access. +TELESRV_STARGIFT_TON_STARTING_GRANT=10000000000 +TELESRV_STARGIFT_TRANSFER_STARS=25 +TELESRV_STARGIFT_DROP_DETAILS_STARS=25 +TELESRV_STARGIFT_OFFER_MIN_STARS=1 +TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE=1000 +TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE=1000 +TELESRV_STARGIFT_EXPORT_DELAY=0s +TELESRV_STARGIFT_TRANSFER_DELAY=0s +TELESRV_STARGIFT_RESELL_DELAY=0s +TELESRV_STARGIFT_CRAFT_DELAY=0s +TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE=250 TELESRV_BLOB_DIR=data/blobs TELESRV_STICKER_SEED_DIR=data/sticker-seed diff --git a/cmd/giftfetch/main.go b/cmd/giftfetch/main.go new file mode 100644 index 00000000..7736ab6e --- /dev/null +++ b/cmd/giftfetch/main.go @@ -0,0 +1,1042 @@ +// Command giftfetch snapshots the official Telegram star-gift catalog, the +// complete current upgrade-attribute pools, and all document resources +// referenced by either response. It is a read-only fetcher: it never imports +// data into telesrv and never copies the authorization session. +// +// Usage: +// +// SESSION=/path/to/session giftfetch -out +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/iamxvbaba/td/bin" + "github.com/iamxvbaba/td/telegram" + "github.com/iamxvbaba/td/telegram/downloader" + "github.com/iamxvbaba/td/tg" + "golang.org/x/sync/errgroup" + + "telesrv/internal/app/stargifts" +) + +const ( + apiID = 17349 + apiHash = "344583e45741c457fe1862106095a5eb" + defaultMaxDocBytes = int64(16 << 20) + defaultWorkers = 8 + maxCatalogGifts = 5000 + maxUpgradeAttrs = 10000 + maxDocuments = 50000 + maxWorkers = 128 + floodWaitMargin = 5 * time.Second +) + +type catalogManifest struct { + Schema int `json:"schema"` + Hash int `json:"hash"` + RawCatalog fileArtifact `json:"raw_catalog"` + GiftCount int `json:"gift_count"` + ChatCount int `json:"chat_count"` + UserCount int `json:"user_count"` + UpgradeableGiftCount int `json:"upgradeable_gift_count"` + UpgradeAttributeSetCount int `json:"upgrade_attribute_set_count"` + UpgradeAttributeCount int `json:"upgrade_attribute_count"` + UpgradeModelCount int `json:"upgrade_model_count"` + UpgradePatternCount int `json:"upgrade_pattern_count"` + UpgradeBackdropCount int `json:"upgrade_backdrop_count"` + MissingThumbCount int `json:"missing_thumb_count"` + Gifts []giftManifest `json:"gifts"` + UpgradeAttributeSets []upgradeAttributeSetManifest `json:"upgrade_attribute_sets"` + Documents []documentManifest `json:"documents"` + TotalBytes int64 `json:"total_document_bytes"` + BoundaryNote string `json:"boundary_note"` +} + +type giftManifest struct { + Index int `json:"index"` + Kind string `json:"kind"` + ID int64 `json:"id"` + GiftID int64 `json:"gift_id,omitempty"` + Title string `json:"title,omitempty"` + Slug string `json:"slug,omitempty"` + Number int `json:"number,omitempty"` + Stars int64 `json:"stars,omitempty"` + ConvertStars int64 `json:"convert_stars,omitempty"` + UpgradeStars int64 `json:"upgrade_stars,omitempty"` + ResellMinStars int64 `json:"resell_min_stars,omitempty"` + Limited bool `json:"limited,omitempty"` + SoldOut bool `json:"sold_out,omitempty"` + Birthday bool `json:"birthday,omitempty"` + RequirePremium bool `json:"require_premium,omitempty"` + LimitedPerUser bool `json:"limited_per_user,omitempty"` + PeerColorAvailable bool `json:"peer_color_available,omitempty"` + Auction bool `json:"auction,omitempty"` + AvailabilityRemains int `json:"availability_remains,omitempty"` + AvailabilityTotal int `json:"availability_total,omitempty"` + AvailabilityResale int64 `json:"availability_resale,omitempty"` + AvailabilityIssued int `json:"availability_issued,omitempty"` + PerUserTotal int `json:"per_user_total,omitempty"` + PerUserRemains int `json:"per_user_remains,omitempty"` + FirstSaleDate int `json:"first_sale_date,omitempty"` + LastSaleDate int `json:"last_sale_date,omitempty"` + LockedUntilDate int `json:"locked_until_date,omitempty"` + AuctionSlug string `json:"auction_slug,omitempty"` + GiftsPerRound int `json:"gifts_per_round,omitempty"` + AuctionStartDate int `json:"auction_start_date,omitempty"` + UpgradeVariants int `json:"upgrade_variants,omitempty"` + DocumentIDs []int64 `json:"document_ids,omitempty"` + Background *backgroundManifest `json:"background,omitempty"` +} + +type backgroundManifest struct { + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + TextColor int `json:"text_color"` +} + +type upgradeAttributeSetManifest struct { + GiftID int64 `json:"gift_id"` + RawAttributes fileArtifact `json:"raw_attributes"` + AttributeCount int `json:"attribute_count"` + Models []upgradeModelManifest `json:"models"` + Patterns []upgradePatternManifest `json:"patterns"` + Backdrops []upgradeBackdropManifest `json:"backdrops"` + DocumentIDs []int64 `json:"document_ids"` +} + +type upgradeModelManifest struct { + Name string `json:"name"` + DocumentID int64 `json:"document_id"` + Crafted bool `json:"crafted"` + Rarity rarityManifest `json:"rarity"` +} + +type upgradePatternManifest struct { + Name string `json:"name"` + DocumentID int64 `json:"document_id"` + Rarity rarityManifest `json:"rarity"` +} + +type upgradeBackdropManifest struct { + Name string `json:"name"` + BackdropID int `json:"backdrop_id"` + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + PatternColor int `json:"pattern_color"` + TextColor int `json:"text_color"` + Rarity rarityManifest `json:"rarity"` +} + +type rarityManifest struct { + Kind string `json:"kind"` + ConstructorID string `json:"constructor_id"` + Permille *int `json:"permille,omitempty"` +} + +type documentManifest struct { + ID int64 `json:"id"` + Date int `json:"date"` + DCID int `json:"dc_id"` + MimeType string `json:"mime_type"` + ExpectedSize int64 `json:"expected_size"` + FileName string `json:"file_name,omitempty"` + StickerAlt string `json:"sticker_alt,omitempty"` + Purposes []string `json:"purposes"` + File fileArtifact `json:"file"` + AnimationValidated bool `json:"animation_validated,omitempty"` + ValidationError string `json:"validation_error,omitempty"` + Thumbs []fileArtifact `json:"thumbs,omitempty"` + MissingThumbs []missingThumb `json:"missing_thumbs,omitempty"` +} + +type missingThumb struct { + Kind string `json:"kind"` + Type string `json:"type"` + ExpectedSize int64 `json:"expected_size"` + Error string `json:"error"` +} + +type fileArtifact struct { + Kind string `json:"kind,omitempty"` + Type string `json:"type,omitempty"` + Path string `json:"path"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type documentSource struct { + document *tg.Document + purposes map[string]struct{} +} + +type addDocumentFunc func(tg.DocumentClass, string) (*tg.Document, error) + +func main() { + outDir := flag.String("out", "", "output directory") + maxDocBytes := flag.Int64("max-document-bytes", defaultMaxDocBytes, "maximum bytes accepted for one document or thumbnail") + skipThumbs := flag.Bool("skip-thumbs", false, "download main documents only") + workers := flag.Int("workers", defaultWorkers, "concurrent document downloads") + reuseMetadata := flag.Bool("reuse-metadata", false, "reuse and strictly decode catalog.tl plus upgrade-attributes/*.tl instead of refetching metadata") + allowedMissingThumbsRaw := flag.String("allow-missing-thumb", "", "comma-separated document_id:photo|video:type entries that may be recorded as explicitly missing after a failed download") + flag.Parse() + if strings.TrimSpace(*outDir) == "" { + fmt.Fprintln(os.Stderr, "usage: SESSION=/path/to/session giftfetch -out ") + os.Exit(2) + } + session := strings.TrimSpace(os.Getenv("SESSION")) + if session == "" { + fmt.Fprintln(os.Stderr, "ERROR: SESSION is required") + os.Exit(2) + } + if *maxDocBytes <= 0 || *maxDocBytes > 256<<20 { + fmt.Fprintln(os.Stderr, "ERROR: max-document-bytes must be in (0, 256 MiB]") + os.Exit(2) + } + if *workers <= 0 || *workers > maxWorkers { + fmt.Fprintf(os.Stderr, "ERROR: workers must be in [1, %d]\n", maxWorkers) + os.Exit(2) + } + allowedMissingThumbs, err := parseAllowedMissingThumbs(*allowedMissingThumbsRaw) + if err != nil { + fmt.Fprintln(os.Stderr, "ERROR:", err) + os.Exit(2) + } + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour) + defer cancel() + client := telegram.NewClient(apiID, apiHash, telegram.Options{ + SessionStorage: &telegram.FileSessionStorage{Path: session}, + }) + if err := client.Run(ctx, func(ctx context.Context) error { + status, err := client.Auth().Status(ctx) + if err != nil { + return fmt.Errorf("auth status: %w", err) + } + if !status.Authorized { + return errors.New("SESSION is not authorized") + } + fmt.Println("[session authorized]") + return fetchCatalog(ctx, client.API(), *outDir, *maxDocBytes, *skipThumbs, *workers, *reuseMetadata, allowedMissingThumbs) + }); err != nil { + fmt.Fprintln(os.Stderr, "ERROR:", err) + os.Exit(1) + } +} + +func fetchCatalog(ctx context.Context, api *tg.Client, outDir string, maxDocBytes int64, skipThumbs bool, workers int, reuseMetadata bool, allowedMissingThumbs map[string]struct{}) error { + if err := os.MkdirAll(outDir, 0o755); err != nil { + return err + } + + var catalog *tg.PaymentsStarGifts + var rawArtifact fileArtifact + if reuseMetadata { + catalog = &tg.PaymentsStarGifts{} + var err error + rawArtifact, err = readTLArtifact(outDir, "catalog.tl", catalog) + if err != nil { + return fmt.Errorf("reuse catalog metadata: %w", err) + } + fmt.Printf("[metadata reused] path=%s\n", rawArtifact.Path) + } else { + result, err := api.PaymentsGetStarGifts(ctx, 0) + if err != nil { + return fmt.Errorf("payments.getStarGifts: %w", err) + } + var ok bool + catalog, ok = result.(*tg.PaymentsStarGifts) + if !ok { + return fmt.Errorf("payments.getStarGifts(hash=0) returned %T", result) + } + var raw bin.Buffer + if err := catalog.Encode(&raw); err != nil { + return fmt.Errorf("encode raw catalog: %w", err) + } + rawArtifact, err = writeArtifact(outDir, "catalog.tl", "tl", "", raw.Buf) + if err != nil { + return err + } + } + if len(catalog.Gifts) > maxCatalogGifts { + return fmt.Errorf("gift catalog has %d entries, limit is %d", len(catalog.Gifts), maxCatalogGifts) + } + manifest := catalogManifest{ + Schema: 2, + Hash: catalog.Hash, + RawCatalog: rawArtifact, + GiftCount: len(catalog.Gifts), + ChatCount: len(catalog.Chats), + UserCount: len(catalog.Users), + BoundaryNote: "payments.getStarGifts(hash=0) plus payments.getStarGiftUpgradeAttributes for every currently upgradeable base gift: complete current official attribute definitions and referenced documents, not deleted historical definitions or precomputed model-pattern-backdrop combinations", + } + + documents := make(map[int64]*documentSource) + addDocument := func(class tg.DocumentClass, purpose string) (*tg.Document, error) { + doc, ok := class.(*tg.Document) + if !ok || doc.ID == 0 { + return nil, fmt.Errorf("%s references invalid document %T", purpose, class) + } + if !hasRenderableStickerAttribute(doc) { + return nil, fmt.Errorf("%s document %d has neither sticker nor custom-emoji attribute", purpose, doc.ID) + } + existing := documents[doc.ID] + if existing == nil { + existing = &documentSource{document: doc, purposes: make(map[string]struct{})} + documents[doc.ID] = existing + } else if existing.document.Size != doc.Size || existing.document.MimeType != doc.MimeType { + return nil, fmt.Errorf("document %d has conflicting metadata", doc.ID) + } + existing.purposes[purpose] = struct{}{} + return doc, nil + } + + for index, class := range catalog.Gifts { + gm, err := collectGift(index, class, addDocument) + if err != nil { + return err + } + manifest.Gifts = append(manifest.Gifts, gm) + } + + upgradeableGiftIDs := collectUpgradeableGiftIDs(catalog.Gifts) + manifest.UpgradeableGiftCount = len(upgradeableGiftIDs) + for _, giftID := range upgradeableGiftIDs { + attributeSet, err := fetchUpgradeAttributeSet(ctx, api, outDir, giftID, addDocument, reuseMetadata) + if err != nil { + return err + } + manifest.UpgradeAttributeSets = append(manifest.UpgradeAttributeSets, attributeSet) + manifest.UpgradeAttributeSetCount++ + manifest.UpgradeAttributeCount += attributeSet.AttributeCount + manifest.UpgradeModelCount += len(attributeSet.Models) + manifest.UpgradePatternCount += len(attributeSet.Patterns) + manifest.UpgradeBackdropCount += len(attributeSet.Backdrops) + fmt.Printf("[upgrade attributes] gift_id=%d models=%d patterns=%d backdrops=%d raw=%s\n", giftID, len(attributeSet.Models), len(attributeSet.Patterns), len(attributeSet.Backdrops), attributeSet.RawAttributes.Path) + } + if manifest.UpgradeAttributeSetCount != manifest.UpgradeableGiftCount { + return fmt.Errorf("upgrade attribute set count mismatch: got %d want %d", manifest.UpgradeAttributeSetCount, manifest.UpgradeableGiftCount) + } + if len(documents) > maxDocuments { + return fmt.Errorf("catalog and upgrade attributes reference %d documents, limit is %d", len(documents), maxDocuments) + } + + ids := make([]int64, 0, len(documents)) + for id := range documents { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + fmt.Printf("[documents discovered] count=%d workers=%d\n", len(ids), workers) + documentResults := make([]documentManifest, len(ids)) + group, downloadCtx := errgroup.WithContext(ctx) + group.SetLimit(workers) + var completed atomic.Int64 + for index, id := range ids { + index, id := index, id + group.Go(func() error { + dl := downloader.NewDownloader().WithRetryHandler(func(event downloader.RetryEvent) { + fmt.Printf("[download retry] document=%d operation=%s attempt=%d error=%v\n", id, event.Operation, event.Attempt, event.Err) + if strings.Contains(event.Err.Error(), "FLOOD_WAIT") { + timer := time.NewTimer(floodWaitMargin) + defer timer.Stop() + select { + case <-downloadCtx.Done(): + case <-timer.C: + } + } + }) + dm, err := fetchDocument(downloadCtx, api, dl, outDir, documents[id], maxDocBytes, skipThumbs, allowedMissingThumbs) + if err != nil { + return err + } + documentResults[index] = dm + done := completed.Add(1) + if done == int64(len(ids)) || done%100 == 0 { + fmt.Printf("[documents downloaded] completed=%d total=%d\n", done, len(ids)) + } + return nil + }) + } + if err := group.Wait(); err != nil { + return err + } + for _, dm := range documentResults { + manifest.TotalBytes += dm.File.Size + for _, thumb := range dm.Thumbs { + manifest.TotalBytes += thumb.Size + } + manifest.MissingThumbCount += len(dm.MissingThumbs) + manifest.Documents = append(manifest.Documents, dm) + } + + encoded, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + if err := writeFileAtomic(filepath.Join(outDir, "manifest.json"), append(encoded, '\n')); err != nil { + return err + } + fmt.Printf("[complete] gifts=%d attribute_sets=%d attributes=%d documents=%d missing_thumbs=%d bytes=%d manifest=%s\n", len(manifest.Gifts), len(manifest.UpgradeAttributeSets), manifest.UpgradeAttributeCount, len(manifest.Documents), manifest.MissingThumbCount, manifest.TotalBytes, filepath.Join(outDir, "manifest.json")) + return nil +} + +func collectUpgradeableGiftIDs(classes []tg.StarGiftClass) []int64 { + ids := make([]int64, 0, len(classes)) + for _, class := range classes { + gift, ok := class.(*tg.StarGift) + if !ok || gift.ID == 0 || gift.UpgradeStars <= 0 && gift.UpgradeVariants <= 0 { + continue + } + ids = append(ids, gift.ID) + } + return ids +} + +func fetchUpgradeAttributeSet(ctx context.Context, api *tg.Client, root string, giftID int64, addDocument addDocumentFunc, reuseMetadata bool) (upgradeAttributeSetManifest, error) { + var result *tg.PaymentsStarGiftUpgradeAttributes + var rawArtifact fileArtifact + if reuseMetadata { + result = &tg.PaymentsStarGiftUpgradeAttributes{} + var err error + rawArtifact, err = readTLArtifact(root, filepath.Join("upgrade-attributes", fmt.Sprintf("%d.tl", giftID)), result) + if err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("reuse upgrade attributes for gift %d: %w", giftID, err) + } + } else { + var err error + result, err = api.PaymentsGetStarGiftUpgradeAttributes(ctx, giftID) + if err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("payments.getStarGiftUpgradeAttributes(gift_id=%d): %w", giftID, err) + } + var raw bin.Buffer + if err := result.Encode(&raw); err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("encode upgrade attributes for gift %d: %w", giftID, err) + } + rawArtifact, err = writeArtifact(root, filepath.Join("upgrade-attributes", fmt.Sprintf("%d.tl", giftID)), "tl", "", raw.Buf) + if err != nil { + return upgradeAttributeSetManifest{}, err + } + } + if len(result.Attributes) == 0 { + return upgradeAttributeSetManifest{}, fmt.Errorf("payments.getStarGiftUpgradeAttributes(gift_id=%d) returned no attributes", giftID) + } + if len(result.Attributes) > maxUpgradeAttrs { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d has %d upgrade attributes, limit is %d", giftID, len(result.Attributes), maxUpgradeAttrs) + } + return collectUpgradeAttributes(giftID, result, rawArtifact, addDocument) +} + +func collectUpgradeAttributes(giftID int64, result *tg.PaymentsStarGiftUpgradeAttributes, rawArtifact fileArtifact, addDocument addDocumentFunc) (upgradeAttributeSetManifest, error) { + if result == nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d has nil upgrade-attribute result", giftID) + } + set := upgradeAttributeSetManifest{ + GiftID: giftID, + RawAttributes: rawArtifact, + AttributeCount: len(result.Attributes), + } + documentIDs := make(map[int64]struct{}) + for index, attribute := range result.Attributes { + switch value := attribute.(type) { + case *tg.StarGiftAttributeModel: + rarity, err := collectRarity(value.Rarity) + if err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d model %q rarity: %w", giftID, value.Name, err) + } + doc, err := addDocument(value.Document, fmt.Sprintf("gift:%d:upgrade-model:%s", giftID, value.Name)) + if err != nil { + return upgradeAttributeSetManifest{}, err + } + set.Models = append(set.Models, upgradeModelManifest{Name: value.Name, DocumentID: doc.ID, Crafted: value.GetCrafted(), Rarity: rarity}) + documentIDs[doc.ID] = struct{}{} + case *tg.StarGiftAttributePattern: + rarity, err := collectRarity(value.Rarity) + if err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d pattern %q rarity: %w", giftID, value.Name, err) + } + doc, err := addDocument(value.Document, fmt.Sprintf("gift:%d:upgrade-pattern:%s", giftID, value.Name)) + if err != nil { + return upgradeAttributeSetManifest{}, err + } + set.Patterns = append(set.Patterns, upgradePatternManifest{Name: value.Name, DocumentID: doc.ID, Rarity: rarity}) + documentIDs[doc.ID] = struct{}{} + case *tg.StarGiftAttributeBackdrop: + rarity, err := collectRarity(value.Rarity) + if err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d backdrop %q rarity: %w", giftID, value.Name, err) + } + set.Backdrops = append(set.Backdrops, upgradeBackdropManifest{ + Name: value.Name, BackdropID: value.BackdropID, CenterColor: value.CenterColor, + EdgeColor: value.EdgeColor, PatternColor: value.PatternColor, TextColor: value.TextColor, + Rarity: rarity, + }) + default: + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d upgrade attribute at index %d has unsupported constructor %T", giftID, index, attribute) + } + } + if len(set.Models) == 0 || len(set.Patterns) == 0 || len(set.Backdrops) == 0 { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d incomplete upgrade attributes: models=%d patterns=%d backdrops=%d", giftID, len(set.Models), len(set.Patterns), len(set.Backdrops)) + } + if len(set.Models)+len(set.Patterns)+len(set.Backdrops) != set.AttributeCount { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d parsed %d of %d upgrade attributes", giftID, len(set.Models)+len(set.Patterns)+len(set.Backdrops), set.AttributeCount) + } + for id := range documentIDs { + set.DocumentIDs = append(set.DocumentIDs, id) + } + sort.Slice(set.DocumentIDs, func(i, j int) bool { return set.DocumentIDs[i] < set.DocumentIDs[j] }) + return set, nil +} + +func collectRarity(class tg.StarGiftAttributeRarityClass) (rarityManifest, error) { + switch value := class.(type) { + case *tg.StarGiftAttributeRarity: + permille := value.Permille + return rarityManifest{Kind: "permille", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID()), Permille: &permille}, nil + case *tg.StarGiftAttributeRarityUncommon: + return rarityManifest{Kind: "uncommon", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil + case *tg.StarGiftAttributeRarityRare: + return rarityManifest{Kind: "rare", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil + case *tg.StarGiftAttributeRarityEpic: + return rarityManifest{Kind: "epic", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil + case *tg.StarGiftAttributeRarityLegendary: + return rarityManifest{Kind: "legendary", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil + default: + return rarityManifest{}, fmt.Errorf("unsupported constructor %T", class) + } +} + +func collectGift(index int, class tg.StarGiftClass, addDocument addDocumentFunc) (giftManifest, error) { + switch gift := class.(type) { + case *tg.StarGift: + purpose := fmt.Sprintf("gift:%d:sticker", gift.ID) + doc, err := addDocument(gift.Sticker, purpose) + if err != nil { + return giftManifest{}, err + } + gm := giftManifest{ + Index: index, + Kind: "regular", + ID: gift.ID, + Stars: gift.Stars, + ConvertStars: gift.ConvertStars, + UpgradeStars: gift.UpgradeStars, + ResellMinStars: gift.ResellMinStars, + Limited: gift.Limited, + SoldOut: gift.SoldOut, + Birthday: gift.Birthday, + RequirePremium: gift.RequirePremium, + LimitedPerUser: gift.LimitedPerUser, + PeerColorAvailable: gift.PeerColorAvailable, + Auction: gift.Auction, + AvailabilityRemains: gift.AvailabilityRemains, + AvailabilityTotal: gift.AvailabilityTotal, + AvailabilityResale: gift.AvailabilityResale, + PerUserTotal: gift.PerUserTotal, + PerUserRemains: gift.PerUserRemains, + FirstSaleDate: gift.FirstSaleDate, + LastSaleDate: gift.LastSaleDate, + LockedUntilDate: gift.LockedUntilDate, + AuctionSlug: gift.AuctionSlug, + GiftsPerRound: gift.GiftsPerRound, + AuctionStartDate: gift.AuctionStartDate, + UpgradeVariants: gift.UpgradeVariants, + Title: gift.Title, + DocumentIDs: []int64{doc.ID}, + } + if background, ok := gift.GetBackground(); ok { + gm.Background = &backgroundManifest{CenterColor: background.CenterColor, EdgeColor: background.EdgeColor, TextColor: background.TextColor} + } + return gm, nil + case *tg.StarGiftUnique: + gm := giftManifest{ + Index: index, + Kind: "unique", + ID: gift.ID, + GiftID: gift.GiftID, + Title: gift.Title, + Slug: gift.Slug, + Number: gift.Num, + RequirePremium: gift.RequirePremium, + AvailabilityIssued: gift.AvailabilityIssued, + AvailabilityTotal: gift.AvailabilityTotal, + } + for _, attribute := range gift.Attributes { + var class tg.DocumentClass + var purpose string + switch value := attribute.(type) { + case *tg.StarGiftAttributeModel: + class = value.Document + purpose = fmt.Sprintf("unique:%d:model:%s", gift.ID, value.Name) + case *tg.StarGiftAttributePattern: + class = value.Document + purpose = fmt.Sprintf("unique:%d:pattern:%s", gift.ID, value.Name) + default: + continue + } + doc, err := addDocument(class, purpose) + if err != nil { + return giftManifest{}, err + } + gm.DocumentIDs = append(gm.DocumentIDs, doc.ID) + } + return gm, nil + default: + return giftManifest{}, fmt.Errorf("unsupported gift constructor %T at index %d", class, index) + } +} + +func fetchDocument(ctx context.Context, api *tg.Client, dl *downloader.Downloader, root string, source *documentSource, maxBytes int64, skipThumbs bool, allowedMissingThumbs map[string]struct{}) (documentManifest, error) { + doc := source.document + if doc.Size <= 0 || doc.Size > maxBytes { + return documentManifest{}, fmt.Errorf("document %d size %d is outside (0, %d]", doc.ID, doc.Size, maxBytes) + } + fileName, stickerAlt := documentNames(doc) + ext := documentExtension(fileName, doc.MimeType) + rel := filepath.Join("documents", fmt.Sprintf("%d%s", doc.ID, ext)) + data, reused, err := existingArtifact(root, rel, doc.Size, maxBytes) + if err != nil { + return documentManifest{}, err + } + if !reused { + fmt.Printf("[network fetch] document=%d resource=document expected_size=%d part_size=%d\n", doc.ID, doc.Size, downloadPartSize(doc.Size)) + data, err = download(ctx, api, dl, doc, "", doc.Size, maxBytes) + if err != nil { + return documentManifest{}, fmt.Errorf("download document %d: %w", doc.ID, err) + } + } + if int64(len(data)) != doc.Size { + return documentManifest{}, fmt.Errorf("document %d size mismatch: got %d want %d", doc.ID, len(data), doc.Size) + } + artifact, err := writeArtifact(root, rel, "document", "", data) + if err != nil { + return documentManifest{}, err + } + purposes := make([]string, 0, len(source.purposes)) + for purpose := range source.purposes { + purposes = append(purposes, purpose) + } + sort.Strings(purposes) + dm := documentManifest{ + ID: doc.ID, + Date: doc.Date, + DCID: doc.DCID, + MimeType: doc.MimeType, + ExpectedSize: doc.Size, + FileName: fileName, + StickerAlt: stickerAlt, + Purposes: purposes, + File: artifact, + } + if ext == ".tgs" || strings.EqualFold(doc.MimeType, "application/x-tgsticker") { + validator := &stargifts.Service{} + if _, err := validator.PrepareAnimation(fmt.Sprintf("%d.tgs", doc.ID), data); err != nil { + dm.ValidationError = err.Error() + } else { + dm.AnimationValidated = true + } + } + if skipThumbs { + return dm, nil + } + thumbs, missingThumbs, err := fetchThumbs(ctx, api, dl, root, doc, maxBytes, allowedMissingThumbs) + if err != nil { + return documentManifest{}, err + } + dm.Thumbs = thumbs + dm.MissingThumbs = missingThumbs + return dm, nil +} + +func fetchThumbs(ctx context.Context, api *tg.Client, dl *downloader.Downloader, root string, doc *tg.Document, maxBytes int64, allowedMissingThumbs map[string]struct{}) ([]fileArtifact, []missingThumb, error) { + seen := make(map[string]struct{}) + artifacts := make([]fileArtifact, 0, len(doc.Thumbs)+len(doc.VideoThumbs)) + missing := make([]missingThumb, 0) + for _, class := range doc.Thumbs { + thumbType := class.GetType() + if thumbType == "" { + continue + } + key := "photo:" + thumbType + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + var data []byte + var err error + var expectedSize int64 + downloadAttempted := false + switch value := class.(type) { + case *tg.PhotoCachedSize: + data = append([]byte(nil), value.Bytes...) + case *tg.PhotoStrippedSize: + data = append([]byte(nil), value.Bytes...) + case *tg.PhotoPathSize: + data = append([]byte(nil), value.Bytes...) + case *tg.PhotoSize: + expectedSize = int64(value.Size) + if expectedSize <= 0 || expectedSize > maxBytes { + return nil, nil, fmt.Errorf("document %d photo thumb %q size %d is outside (0, %d]", doc.ID, thumbType, expectedSize, maxBytes) + } + rel := filepath.Join("thumbs", fmt.Sprintf("%d-photo-%s.bin", doc.ID, safePart(thumbType))) + var reused bool + data, reused, err = existingArtifact(root, rel, expectedSize, maxBytes) + if err == nil && !reused { + downloadAttempted = true + fmt.Printf("[network fetch] document=%d resource=photo-thumb type=%s expected_size=%d part_size=%d\n", doc.ID, thumbType, expectedSize, downloadPartSize(expectedSize)) + data, err = download(ctx, api, dl, doc, thumbType, expectedSize, maxBytes) + } + case *tg.PhotoSizeProgressive: + if len(value.Sizes) == 0 { + return nil, nil, fmt.Errorf("document %d progressive photo thumb %q has no sizes", doc.ID, thumbType) + } + expectedSize = int64(value.Sizes[len(value.Sizes)-1]) + if expectedSize <= 0 || expectedSize > maxBytes { + return nil, nil, fmt.Errorf("document %d progressive photo thumb %q size %d is outside (0, %d]", doc.ID, thumbType, expectedSize, maxBytes) + } + rel := filepath.Join("thumbs", fmt.Sprintf("%d-photo-%s.bin", doc.ID, safePart(thumbType))) + var reused bool + data, reused, err = existingArtifact(root, rel, expectedSize, maxBytes) + if err == nil && !reused { + downloadAttempted = true + fmt.Printf("[network fetch] document=%d resource=photo-thumb-progressive type=%s expected_size=%d part_size=%d\n", doc.ID, thumbType, expectedSize, downloadPartSize(expectedSize)) + data, err = download(ctx, api, dl, doc, thumbType, expectedSize, maxBytes) + } + default: + continue + } + if err != nil { + if downloadAttempted && missingThumbAllowed(allowedMissingThumbs, doc.ID, "photo", thumbType) { + missing = append(missing, missingThumb{Kind: "photo", Type: thumbType, ExpectedSize: expectedSize, Error: err.Error()}) + fmt.Printf("[missing thumb] document=%d kind=photo type=%s expected_size=%d error=%v\n", doc.ID, thumbType, expectedSize, err) + continue + } + return nil, nil, fmt.Errorf("download document %d photo thumb %q: %w", doc.ID, thumbType, err) + } + if len(data) == 0 { + continue + } + if expectedSize > 0 && int64(len(data)) != expectedSize { + return nil, nil, fmt.Errorf("document %d photo thumb %q size mismatch: got %d want %d", doc.ID, thumbType, len(data), expectedSize) + } + rel := filepath.Join("thumbs", fmt.Sprintf("%d-photo-%s.bin", doc.ID, safePart(thumbType))) + artifact, err := writeArtifact(root, rel, "photo", thumbType, data) + if err != nil { + return nil, nil, err + } + artifacts = append(artifacts, artifact) + } + for _, class := range doc.VideoThumbs { + value, ok := class.(*tg.VideoSize) + if !ok || value.Type == "" { + continue + } + key := "video:" + value.Type + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + rel := filepath.Join("thumbs", fmt.Sprintf("%d-video-%s.bin", doc.ID, safePart(value.Type))) + data, reused, err := existingArtifact(root, rel, int64(value.Size), maxBytes) + downloadAttempted := false + if err == nil && !reused { + downloadAttempted = true + fmt.Printf("[network fetch] document=%d resource=video-thumb type=%s expected_size=%d part_size=%d\n", doc.ID, value.Type, value.Size, downloadPartSize(int64(value.Size))) + data, err = download(ctx, api, dl, doc, value.Type, int64(value.Size), maxBytes) + } + if err != nil { + if downloadAttempted && missingThumbAllowed(allowedMissingThumbs, doc.ID, "video", value.Type) { + missing = append(missing, missingThumb{Kind: "video", Type: value.Type, ExpectedSize: int64(value.Size), Error: err.Error()}) + fmt.Printf("[missing thumb] document=%d kind=video type=%s expected_size=%d error=%v\n", doc.ID, value.Type, value.Size, err) + continue + } + return nil, nil, fmt.Errorf("download document %d video thumb %q: %w", doc.ID, value.Type, err) + } + artifact, err := writeArtifact(root, rel, "video", value.Type, data) + if err != nil { + return nil, nil, err + } + artifacts = append(artifacts, artifact) + } + sort.Slice(artifacts, func(i, j int) bool { + if artifacts[i].Kind != artifacts[j].Kind { + return artifacts[i].Kind < artifacts[j].Kind + } + return artifacts[i].Type < artifacts[j].Type + }) + sort.Slice(missing, func(i, j int) bool { + if missing[i].Kind != missing[j].Kind { + return missing[i].Kind < missing[j].Kind + } + return missing[i].Type < missing[j].Type + }) + return artifacts, missing, nil +} + +func download(ctx context.Context, api *tg.Client, dl *downloader.Downloader, doc *tg.Document, thumbType string, expectedSize, maxBytes int64) ([]byte, error) { + location := &tg.InputDocumentFileLocation{ + ID: doc.ID, + AccessHash: doc.AccessHash, + FileReference: doc.FileReference, + ThumbSize: thumbType, + } + partSize := downloadPartSize(expectedSize) + if expectedSize > 0 && expectedSize < int64(partSize) { + // TDesktop and DrKLO issue ordinary non-precise upload.getFile requests + // for regular file chunks. A known-size resource that fits in one valid + // chunk needs neither gotd's precise mode nor an EOF probe. + result, err := api.UploadGetFile(ctx, &tg.UploadGetFileRequest{ + Location: location, + Offset: 0, + Limit: partSize, + }) + if err != nil { + return nil, err + } + file, ok := result.(*tg.UploadFile) + if !ok { + return nil, fmt.Errorf("single-chunk upload.getFile returned %T", result) + } + if int64(len(file.Bytes)) > maxBytes { + return nil, fmt.Errorf("download exceeds %d bytes", maxBytes) + } + return append([]byte(nil), file.Bytes...), nil + } + buffer := &boundedBuffer{max: maxBytes} + if _, err := dl.WithPartSize(partSize).Download(api, location).Stream(ctx, buffer); err != nil { + return nil, err + } + return append([]byte(nil), buffer.Bytes()...), nil +} + +func downloadPartSize(expectedSize int64) int { + const ( + unit = int64(4 << 10) + max = int64(512 << 10) + ) + if expectedSize <= 0 || expectedSize >= max { + return int(max) + } + // Choose a valid 4 KiB-aligned limit strictly larger than the file whenever + // possible, so downloader.Stream recognizes the first short chunk as final + // without an extra EOF probe. + partSize := ((expectedSize + 1 + unit - 1) / unit) * unit + if partSize > max { + partSize = max + } + return int(partSize) +} + +func existingArtifact(root, relative string, expectedSize, maxBytes int64) ([]byte, bool, error) { + data, err := os.ReadFile(filepath.Join(root, relative)) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("read existing artifact %q: %w", relative, err) + } + size := int64(len(data)) + if size <= 0 || size > maxBytes || expectedSize >= 0 && size != expectedSize { + return nil, false, nil + } + return data, true, nil +} + +type boundedBuffer struct { + bytes.Buffer + max int64 +} + +func (b *boundedBuffer) Write(p []byte) (int, error) { + remaining := b.max - int64(b.Len()) + if remaining <= 0 { + return 0, fmt.Errorf("download exceeds %d bytes", b.max) + } + if int64(len(p)) > remaining { + written, _ := b.Buffer.Write(p[:remaining]) + return written, fmt.Errorf("download exceeds %d bytes", b.max) + } + return b.Buffer.Write(p) +} + +func hasRenderableStickerAttribute(doc *tg.Document) bool { + for _, attribute := range doc.Attributes { + switch attribute.(type) { + case *tg.DocumentAttributeSticker, *tg.DocumentAttributeCustomEmoji: + return true + } + } + return false +} + +func documentNames(doc *tg.Document) (fileName, alt string) { + for _, attribute := range doc.Attributes { + switch value := attribute.(type) { + case *tg.DocumentAttributeFilename: + fileName = filepath.Base(value.FileName) + case *tg.DocumentAttributeSticker: + alt = value.Alt + case *tg.DocumentAttributeCustomEmoji: + alt = value.Alt + } + } + return fileName, alt +} + +func documentExtension(fileName, mimeType string) string { + ext := strings.ToLower(filepath.Ext(fileName)) + switch ext { + case ".tgs", ".webm", ".mp4", ".webp", ".png", ".jpg", ".jpeg": + return ext + } + switch strings.ToLower(mimeType) { + case "application/x-tgsticker", "application/gzip": + return ".tgs" + case "video/webm": + return ".webm" + case "video/mp4": + return ".mp4" + case "image/webp": + return ".webp" + case "image/png": + return ".png" + case "image/jpeg": + return ".jpg" + default: + return ".bin" + } +} + +func safePart(value string) string { + var builder strings.Builder + for _, r := range value { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' { + builder.WriteRune(r) + } + } + if builder.Len() == 0 { + return "unknown" + } + return builder.String() +} + +func parseAllowedMissingThumbs(raw string) (map[string]struct{}, error) { + allowed := make(map[string]struct{}) + for _, entry := range strings.Split(raw, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + parts := strings.Split(entry, ":") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid allow-missing-thumb %q: want document_id:photo|video:type", entry) + } + documentID, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || documentID <= 0 { + return nil, fmt.Errorf("invalid allow-missing-thumb document ID %q", parts[0]) + } + kind := parts[1] + if kind != "photo" && kind != "video" { + return nil, fmt.Errorf("invalid allow-missing-thumb kind %q", kind) + } + thumbType := parts[2] + if thumbType == "" || safePart(thumbType) != thumbType { + return nil, fmt.Errorf("invalid allow-missing-thumb type %q", thumbType) + } + allowed[missingThumbKey(documentID, kind, thumbType)] = struct{}{} + } + return allowed, nil +} + +func missingThumbAllowed(allowed map[string]struct{}, documentID int64, kind, thumbType string) bool { + _, ok := allowed[missingThumbKey(documentID, kind, thumbType)] + return ok +} + +func missingThumbKey(documentID int64, kind, thumbType string) string { + return fmt.Sprintf("%d:%s:%s", documentID, kind, thumbType) +} + +func writeArtifact(root, relative, kind, artifactType string, data []byte) (fileArtifact, error) { + path := filepath.Join(root, relative) + if err := writeFileAtomic(path, data); err != nil { + return fileArtifact{}, err + } + sum := sha256.Sum256(data) + return fileArtifact{ + Kind: kind, + Type: artifactType, + Path: filepath.ToSlash(relative), + Size: int64(len(data)), + SHA256: hex.EncodeToString(sum[:]), + }, nil +} + +func readTLArtifact(root, relative string, target interface{ Decode(*bin.Buffer) error }) (fileArtifact, error) { + data, err := os.ReadFile(filepath.Join(root, relative)) + if err != nil { + return fileArtifact{}, err + } + if len(data) == 0 { + return fileArtifact{}, fmt.Errorf("TL artifact %q is empty", relative) + } + buffer := &bin.Buffer{Buf: data} + if err := target.Decode(buffer); err != nil { + return fileArtifact{}, fmt.Errorf("decode TL artifact %q: %w", relative, err) + } + if len(buffer.Buf) != 0 { + return fileArtifact{}, fmt.Errorf("TL artifact %q has %d trailing bytes", relative, len(buffer.Buf)) + } + sum := sha256.Sum256(data) + return fileArtifact{ + Kind: "tl", + Path: filepath.ToSlash(relative), + Size: int64(len(data)), + SHA256: hex.EncodeToString(sum[:]), + }, nil +} + +func writeFileAtomic(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".giftfetch-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o644); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) { + return fmt.Errorf("replace %q: %w (remove existing: %v)", path, err, removeErr) + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + } + return nil +} diff --git a/cmd/giftfetch/main_test.go b/cmd/giftfetch/main_test.go new file mode 100644 index 00000000..93233f80 --- /dev/null +++ b/cmd/giftfetch/main_test.go @@ -0,0 +1,238 @@ +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/iamxvbaba/td/bin" + "github.com/iamxvbaba/td/tg" +) + +func TestHasRenderableStickerAttribute(t *testing.T) { + tests := []struct { + name string + attributes []tg.DocumentAttributeClass + want bool + }{ + {name: "sticker", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeSticker{}}, want: true}, + {name: "custom emoji", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeCustomEmoji{}}, want: true}, + {name: "ordinary file", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeFilename{}}, want: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := hasRenderableStickerAttribute(&tg.Document{Attributes: test.attributes}); got != test.want { + t.Fatalf("hasRenderableStickerAttribute() = %v, want %v", got, test.want) + } + }) + } +} + +func TestDocumentExtension(t *testing.T) { + tests := []struct { + name string + mime string + want string + }{ + {name: "gift.tgs", mime: "application/octet-stream", want: ".tgs"}, + {name: "", mime: "application/x-tgsticker", want: ".tgs"}, + {name: "unsafe.exe", mime: "video/webm", want: ".webm"}, + {name: "", mime: "application/octet-stream", want: ".bin"}, + } + for _, test := range tests { + if got := documentExtension(test.name, test.mime); got != test.want { + t.Errorf("documentExtension(%q, %q) = %q, want %q", test.name, test.mime, got, test.want) + } + } +} + +func TestBoundedBuffer(t *testing.T) { + buffer := &boundedBuffer{max: 4} + if _, err := buffer.Write([]byte("abc")); err != nil { + t.Fatal(err) + } + if written, err := buffer.Write([]byte("def")); err == nil || written != 1 { + t.Fatalf("overflow write = (%d, %v), want (1, error)", written, err) + } + if !bytes.Equal(buffer.Bytes(), []byte("abcd")) { + t.Fatalf("buffer = %q, want abcd", buffer.Bytes()) + } +} + +func TestDownloadPartSize(t *testing.T) { + tests := []struct { + size int64 + want int + }{ + {size: 1, want: 4 << 10}, + {size: (4 << 10) - 1, want: 4 << 10}, + {size: 4 << 10, want: 8 << 10}, + {size: (512 << 10) - 1, want: 512 << 10}, + {size: 512 << 10, want: 512 << 10}, + {size: 1 << 20, want: 512 << 10}, + } + for _, test := range tests { + if got := downloadPartSize(test.size); got != test.want { + t.Errorf("downloadPartSize(%d) = %d, want %d", test.size, got, test.want) + } + } +} + +func TestParseAllowedMissingThumbs(t *testing.T) { + allowed, err := parseAllowedMissingThumbs("5417911440709285239:photo:m,42:video:v") + if err != nil { + t.Fatal(err) + } + if !missingThumbAllowed(allowed, 5417911440709285239, "photo", "m") || !missingThumbAllowed(allowed, 42, "video", "v") { + t.Fatalf("allowed = %v", allowed) + } + for _, invalid := range []string{"bad", "0:photo:m", "1:audio:m", "1:photo:?"} { + if _, err := parseAllowedMissingThumbs(invalid); err == nil { + t.Errorf("parseAllowedMissingThumbs(%q) succeeded", invalid) + } + } +} + +func TestExistingArtifact(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "resource.bin"), []byte("gift"), 0o644); err != nil { + t.Fatal(err) + } + data, reused, err := existingArtifact(root, "resource.bin", 4, 16) + if err != nil || !reused || string(data) != "gift" { + t.Fatalf("existingArtifact(valid) = (%q, %v, %v)", data, reused, err) + } + if _, reused, err := existingArtifact(root, "resource.bin", 5, 16); err != nil || reused { + t.Fatalf("existingArtifact(size mismatch) = (reused=%v, err=%v)", reused, err) + } + if _, reused, err := existingArtifact(root, "missing.bin", -1, 16); err != nil || reused { + t.Fatalf("existingArtifact(missing) = (reused=%v, err=%v)", reused, err) + } +} + +func TestReadTLArtifact(t *testing.T) { + root := t.TempDir() + var encoded bin.Buffer + if err := (&tg.PaymentsStarGiftUpgradeAttributes{}).Encode(&encoded); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "attributes.tl"), encoded.Buf, 0o644); err != nil { + t.Fatal(err) + } + var decoded tg.PaymentsStarGiftUpgradeAttributes + artifact, err := readTLArtifact(root, "attributes.tl", &decoded) + if err != nil { + t.Fatal(err) + } + if artifact.Kind != "tl" || artifact.Size != int64(len(encoded.Buf)) || artifact.SHA256 == "" { + t.Fatalf("artifact = %+v", artifact) + } + + if err := os.WriteFile(filepath.Join(root, "trailing.tl"), append(append([]byte(nil), encoded.Buf...), 0xff), 0o644); err != nil { + t.Fatal(err) + } + if _, err := readTLArtifact(root, "trailing.tl", &tg.PaymentsStarGiftUpgradeAttributes{}); err == nil { + t.Fatal("expected trailing-byte error") + } +} + +func TestCollectUpgradeableGiftIDs(t *testing.T) { + classes := []tg.StarGiftClass{ + &tg.StarGift{ID: 1, UpgradeStars: 10}, + &tg.StarGift{ID: 2, UpgradeVariants: 3}, + &tg.StarGift{ID: 3}, + &tg.StarGiftUnique{ID: 4, GiftID: 1}, + } + got := collectUpgradeableGiftIDs(classes) + if len(got) != 2 || got[0] != 1 || got[1] != 2 { + t.Fatalf("collectUpgradeableGiftIDs() = %v, want [1 2]", got) + } +} + +func TestCollectUpgradeAttributes(t *testing.T) { + modelDoc := testGiftDocument(101) + patternDoc := testGiftDocument(102) + model := &tg.StarGiftAttributeModel{ + Name: "Crafted model", + Document: modelDoc, + Rarity: &tg.StarGiftAttributeRarityLegendary{}, + } + model.SetCrafted(true) + result := &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{ + model, + &tg.StarGiftAttributePattern{Name: "Pattern", Document: patternDoc, Rarity: &tg.StarGiftAttributeRarity{Permille: 125}}, + &tg.StarGiftAttributeBackdrop{Name: "Backdrop", BackdropID: 7, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4, Rarity: &tg.StarGiftAttributeRarityEpic{}}, + }} + added := make(map[int64]string) + set, err := collectUpgradeAttributes(99, result, fileArtifact{Path: "upgrade-attributes/99.tl"}, func(class tg.DocumentClass, purpose string) (*tg.Document, error) { + doc, ok := class.(*tg.Document) + if !ok { + return nil, errors.New("not a document") + } + added[doc.ID] = purpose + return doc, nil + }) + if err != nil { + t.Fatal(err) + } + if set.AttributeCount != 3 || len(set.Models) != 1 || len(set.Patterns) != 1 || len(set.Backdrops) != 1 { + t.Fatalf("unexpected attribute counts: %+v", set) + } + if !set.Models[0].Crafted || set.Models[0].Rarity.Kind != "legendary" { + t.Fatalf("model = %+v", set.Models[0]) + } + if set.Patterns[0].Rarity.Permille == nil || *set.Patterns[0].Rarity.Permille != 125 { + t.Fatalf("pattern rarity = %+v", set.Patterns[0].Rarity) + } + if set.Backdrops[0].PatternColor != 3 || set.Backdrops[0].Rarity.Kind != "epic" { + t.Fatalf("backdrop = %+v", set.Backdrops[0]) + } + if len(set.DocumentIDs) != 2 || len(added) != 2 { + t.Fatalf("document ids = %v, added = %v", set.DocumentIDs, added) + } +} + +func TestCollectUpgradeAttributesRejectsInstanceOnlyAttribute(t *testing.T) { + _, err := collectUpgradeAttributes(99, &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{ + &tg.StarGiftAttributeOriginalDetails{}, + }}, fileArtifact{}, func(tg.DocumentClass, string) (*tg.Document, error) { + return nil, nil + }) + if err == nil { + t.Fatal("expected unsupported-constructor error") + } +} + +func TestCollectRarityKinds(t *testing.T) { + tests := []struct { + class tg.StarGiftAttributeRarityClass + kind string + }{ + {class: &tg.StarGiftAttributeRarityUncommon{}, kind: "uncommon"}, + {class: &tg.StarGiftAttributeRarityRare{}, kind: "rare"}, + {class: &tg.StarGiftAttributeRarityEpic{}, kind: "epic"}, + {class: &tg.StarGiftAttributeRarityLegendary{}, kind: "legendary"}, + } + for _, test := range tests { + got, err := collectRarity(test.class) + if err != nil || got.Kind != test.kind || got.ConstructorID == "" { + t.Fatalf("collectRarity(%T) = (%+v, %v)", test.class, got, err) + } + } + if _, err := collectRarity(nil); err == nil { + t.Fatal("expected nil-rarity error") + } +} + +func testGiftDocument(id int64) *tg.Document { + return &tg.Document{ + ID: id, + Size: 1, + MimeType: "application/x-tgsticker", + Attributes: []tg.DocumentAttributeClass{ + &tg.DocumentAttributeCustomEmoji{Alt: "gift"}, + }, + } +} diff --git a/cmd/telesrv-admin/readstore.go b/cmd/telesrv-admin/readstore.go index dae46000..6ebd08e6 100644 --- a/cmd/telesrv-admin/readstore.go +++ b/cmd/telesrv-admin/readstore.go @@ -134,23 +134,23 @@ type ChannelDetail struct { } type StarGiftRow struct { - GiftID int64 - RevisionID int64 + GiftID int64 `json:"GiftID,string"` + RevisionID int64 `json:"RevisionID,string"` Revision int Title string - Stars int64 - ConvertStars int64 + Stars int64 `json:"Stars,string"` + ConvertStars int64 `json:"ConvertStars,string"` Enabled bool SortOrder int - DocumentID int64 + DocumentID int64 `json:"DocumentID,string"` SourceName string SourceFormat string AnimationSHA string - AnimationSize int64 + AnimationSize int64 `json:"AnimationSize,string"` Width int Height int FrameRate float64 - ReceivedCount int64 + ReceivedCount int64 `json:"ReceivedCount,string"` CreatedBy string UpdatedAt time.Time } diff --git a/cmd/telesrv-admin/server.go b/cmd/telesrv-admin/server.go index c72ab72b..8318ae2e 100644 --- a/cmd/telesrv-admin/server.go +++ b/cmd/telesrv-admin/server.go @@ -58,6 +58,8 @@ func (s *server) routes() http.Handler { mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI))) mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI))) mux.Handle("GET /api/gifts", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftsAPI))) + mux.Handle("GET /api/official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftsAPI))) + mux.Handle("GET /api/official-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftAnimationAPI))) mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI))) mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI))) mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI))) @@ -70,6 +72,7 @@ func (s *server) routes() http.Handler { mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI))) mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI))) mux.Handle("POST /api/actions/import-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportStarGiftAPI))) + mux.Handle("POST /api/actions/import-official-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportOfficialStarGiftAPI))) mux.Handle("POST /api/actions/publish-gift-collectibles", s.requireAuthAPI(http.HandlerFunc(s.handlePublishStarGiftCollectiblesAPI))) mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI))) mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI))) @@ -228,6 +231,19 @@ func (s *server) handleStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Re s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles", giftID), 4<<20) } +func (s *server) handleOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) { + s.proxyAdminJSON(w, r, "/v1/official-gifts", 4<<20) +} + +func (s *server) handleOfficialStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if _, err := strconv.ParseInt(id, 10, 64); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid official gift id") + return + } + s.proxyAdminJSON(w, r, "/v1/official-gifts/"+id+"/animation", 4<<20) +} + func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) { giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64) @@ -696,10 +712,10 @@ type importStarGiftAPIRequest struct { CommandID string `json:"command_id"` Reason string `json:"reason"` Confirm bool `json:"confirm"` - GiftID int64 `json:"gift_id"` + GiftID int64 `json:"gift_id,string"` Title string `json:"title"` - Stars int64 `json:"stars"` - ConvertStars int64 `json:"convert_stars"` + Stars int64 `json:"stars,string"` + ConvertStars int64 `json:"convert_stars,string"` Enabled bool `json:"enabled"` SortOrder int `json:"sort_order"` } @@ -746,11 +762,48 @@ func (s *server) handleImportStarGiftAPI(w http.ResponseWriter, r *http.Request) writeCommandResultAPI(w, result, err) } +type importOfficialStarGiftAPIRequest struct { + CommandID string `json:"command_id"` + Reason string `json:"reason"` + Confirm bool `json:"confirm"` + SourceGiftID string `json:"source_gift_id"` + GiftID int64 `json:"gift_id,string"` + Title string `json:"title"` + Stars int64 `json:"stars,string"` + ConvertStars int64 `json:"convert_stars,string"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sort_order"` + IncludeCollectible bool `json:"include_collectible"` + UpgradeStars int64 `json:"upgrade_stars,string"` + SupplyTotal int `json:"supply_total"` + SlugPrefix string `json:"slug_prefix"` +} + +func (s *server) handleImportOfficialStarGiftAPI(w http.ResponseWriter, r *http.Request) { + var body importOfficialStarGiftAPIRequest + if !decodeAction(w, r, &body) { + return + } + if _, err := strconv.ParseInt(strings.TrimSpace(body.SourceGiftID), 10, 64); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid official gift id") + return + } + req := admin.ImportOfficialStarGiftRequest{ + CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-official-gift"), + SourceGiftID: body.SourceGiftID, GiftID: body.GiftID, Title: body.Title, + Stars: body.Stars, ConvertStars: body.ConvertStars, Enabled: body.Enabled, SortOrder: body.SortOrder, + IncludeCollectible: body.IncludeCollectible, UpgradeStars: body.UpgradeStars, + SupplyTotal: body.SupplyTotal, SlugPrefix: body.SlugPrefix, + } + result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import", req) + writeCommandResultAPI(w, result, err) +} + type publishStarGiftCollectiblesAPIRequest struct { CommandID string `json:"command_id"` Reason string `json:"reason"` Confirm bool `json:"confirm"` - UpgradeStars int64 `json:"upgrade_stars"` + UpgradeStars int64 `json:"upgrade_stars,string"` SupplyTotal int `json:"supply_total"` SlugPrefix string `json:"slug_prefix"` Models []admin.StarGiftCollectibleAnimationUpload `json:"models"` @@ -832,7 +885,7 @@ type setStarGiftEnabledAPIRequest struct { CommandID string `json:"command_id"` Reason string `json:"reason"` Confirm bool `json:"confirm"` - GiftID int64 `json:"gift_id"` + GiftID int64 `json:"gift_id,string"` Enabled bool `json:"enabled"` } @@ -853,7 +906,7 @@ type setStarGiftSortOrderAPIRequest struct { CommandID string `json:"command_id"` Reason string `json:"reason"` Confirm bool `json:"confirm"` - GiftID int64 `json:"gift_id"` + GiftID int64 `json:"gift_id,string"` SortOrder int `json:"sort_order"` } diff --git a/cmd/telesrv-admin/session_test.go b/cmd/telesrv-admin/session_test.go index 51f88416..40c6a3ef 100644 --- a/cmd/telesrv-admin/session_test.go +++ b/cmd/telesrv-admin/session_test.go @@ -82,3 +82,76 @@ func TestSetAccountFrozenBFFForwardsClientVisibleState(t *testing.T) { t.Fatalf("forwarded freeze request = %+v", got) } } + +func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) { + const maxInt64 = int64(9223372036854775807) + raw, err := json.Marshal(StarGiftRow{ + GiftID: maxInt64, + RevisionID: maxInt64, + Stars: maxInt64, + ConvertStars: maxInt64, + DocumentID: maxInt64, + AnimationSize: maxInt64, + ReceivedCount: maxInt64, + }) + if err != nil { + t.Fatalf("marshal star gift row: %v", err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal star gift row: %v", err) + } + for _, field := range []string{"GiftID", "RevisionID", "Stars", "ConvertStars", "DocumentID", "AnimationSize", "ReceivedCount"} { + if got[field] != "9223372036854775807" { + t.Fatalf("%s = %#v, want exact decimal string", field, got[field]) + } + } +} + +func TestStarGiftActionDecimalStringDecodingPreservesInt64(t *testing.T) { + const maxInt64 = int64(9223372036854775807) + req := httptest.NewRequest(http.MethodPost, "/api/actions/import-official-gift", strings.NewReader(`{ + "source_gift_id":"5895603153683874485", + "gift_id":"9223372036854775807", + "stars":"9223372036854775807", + "convert_stars":"9223372036854775807", + "upgrade_stars":"9223372036854775807" + }`)) + var got importOfficialStarGiftAPIRequest + if err := decodeJSON(req, &got); err != nil { + t.Fatalf("decode gift action: %v", err) + } + if got.GiftID != maxInt64 || got.Stars != maxInt64 || got.ConvertStars != maxInt64 || got.UpgradeStars != maxInt64 { + t.Fatalf("decoded gift action = %+v", got) + } +} + +func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) { + const maxInt64 = int64(9223372036854775807) + var got admin.SetStarGiftEnabledRequest + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/gifts/set-enabled" || r.Header.Get("Authorization") != "Bearer secret" { + t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization")) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + _ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun}) + })) + defer upstream.Close() + + srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}} + req := httptest.NewRequest(http.MethodPost, "/api/actions/set-gift-enabled", strings.NewReader(`{ + "reason":"precision regression","confirm":false, + "gift_id":"9223372036854775807","enabled":false + }`)) + req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator")) + rec := httptest.NewRecorder() + srv.handleSetStarGiftEnabledAPI(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if got.GiftID != maxInt64 || got.Actor != "operator" || !got.DryRun { + t.Fatalf("forwarded gift request = %+v", got) + } +} diff --git a/cmd/telesrv-admin/web/dist/assets/index-BFkUM6v2.js b/cmd/telesrv-admin/web/dist/assets/index-BFkUM6v2.js deleted file mode 100644 index 595890ee..00000000 --- a/cmd/telesrv-admin/web/dist/assets/index-BFkUM6v2.js +++ /dev/null @@ -1,9 +0,0 @@ -var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.provider`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.iterator;function p(e){return typeof e!=`object`||!e?null:(e=f&&e[f]||e[`@@iterator`],typeof e==`function`?e:null)}var m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function _(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}_.prototype.isReactComponent={},_.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`setState(...): takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},_.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function v(){}v.prototype=_.prototype;function y(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}var b=y.prototype=new v;b.constructor=y,h(b,_.prototype),b.isPureReactComponent=!0;var x=Array.isArray,S=Object.prototype.hasOwnProperty,C={current:null},w={key:!0,ref:!0,__self:!0,__source:!0};function T(e,n,r){var i,a={},o=null,s=null;if(n!=null)for(i in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(o=``+n.key),n)S.call(n,i)&&!w.hasOwnProperty(i)&&(a[i]=n[i]);var c=arguments.length-2;if(c===1)a.children=r;else if(1{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=typeof setTimeout==`function`?setTimeout:null,_=typeof clearTimeout==`function`?clearTimeout:null,v=typeof setImmediate<`u`?setImmediate:null;typeof navigator<`u`&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function b(e){if(h=!1,y(e),!m)if(n(c)!==null)m=!0,M(x);else{var t=n(l);t!==null&&N(b,t.startTime-e)}}function x(t,i){m=!1,h&&(h=!1,_(w),w=-1),p=!0;var a=f;try{for(y(i),d=n(c);d!==null&&(!(d.expirationTime>i)||t&&!D());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=i);i=e.unstable_now(),typeof s==`function`?d.callback=s:d===n(c)&&r(c),y(i)}else r(c);d=n(c)}if(d!==null)var u=!0;else{var g=n(l);g!==null&&N(b,g.startTime-i),u=!1}return u}finally{d=null,f=a,p=!1}}var S=!1,C=null,w=-1,T=5,E=-1;function D(){return!(e.unstable_now()-Ee||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(_(w),w=-1):h=!0,N(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,M(x))),r},e.unstable_shouldYield=D,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u(),n=f();function r(e){for(var t=`https://reactjs.org/docs/error-decoder.html?invariant=`+e,n=1;n`u`||window.document===void 0||window.document.createElement===void 0),l=Object.prototype.hasOwnProperty,d=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,p={},m={};function h(e){return l.call(m,e)?!0:l.call(p,e)?!1:d.test(e)?m[e]=!0:(p[e]=!0,!1)}function g(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case`function`:case`symbol`:return!0;case`boolean`:return r?!1:n===null?(e=e.toLowerCase().slice(0,5),e!==`data-`&&e!==`aria-`):!n.acceptsBooleans;default:return!1}}function _(e,t,n,r){if(t==null||g(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return!1===t;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function v(e,t,n,r,i,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var y={};`children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style`.split(` `).forEach(function(e){y[e]=new v(e,0,!1,e,null,!1,!1)}),[[`acceptCharset`,`accept-charset`],[`className`,`class`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`]].forEach(function(e){var t=e[0];y[t]=new v(t,1,!1,e[1],null,!1,!1)}),[`contentEditable`,`draggable`,`spellCheck`,`value`].forEach(function(e){y[e]=new v(e,2,!1,e.toLowerCase(),null,!1,!1)}),[`autoReverse`,`externalResourcesRequired`,`focusable`,`preserveAlpha`].forEach(function(e){y[e]=new v(e,2,!1,e,null,!1,!1)}),`allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope`.split(` `).forEach(function(e){y[e]=new v(e,3,!1,e.toLowerCase(),null,!1,!1)}),[`checked`,`multiple`,`muted`,`selected`].forEach(function(e){y[e]=new v(e,3,!0,e,null,!1,!1)}),[`capture`,`download`].forEach(function(e){y[e]=new v(e,4,!1,e,null,!1,!1)}),[`cols`,`rows`,`size`,`span`].forEach(function(e){y[e]=new v(e,6,!1,e,null,!1,!1)}),[`rowSpan`,`start`].forEach(function(e){y[e]=new v(e,5,!1,e.toLowerCase(),null,!1,!1)});var b=/[\-:]([a-z])/g;function x(e){return e[1].toUpperCase()}`accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,null,!1,!1)}),`xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/1999/xlink`,!1,!1)}),[`xml:base`,`xml:lang`,`xml:space`].forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/XML/1998/namespace`,!1,!1)}),[`tabIndex`,`crossOrigin`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!1,!1)}),y.xlinkHref=new v(`xlinkHref`,1,!1,`xlink:href`,`http://www.w3.org/1999/xlink`,!0,!1),[`src`,`href`,`action`,`formAction`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!0,!0)});function S(e,t,n,r){var i=y.hasOwnProperty(t)?y[t]:null;(i===null?r||!(2s||i[o]!==a[s]){var c=` -`+i[o].replace(` at new `,` at `);return e.displayName&&c.includes(``)&&(c=c.replace(``,e.displayName)),c}while(1<=o&&0<=s);break}}}finally{re=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:``)?ne(e):``}function ae(e){switch(e.tag){case 5:return ne(e.type);case 16:return ne(`Lazy`);case 13:return ne(`Suspense`);case 19:return ne(`SuspenseList`);case 0:case 2:case 15:return e=ie(e.type,!1),e;case 11:return e=ie(e.type.render,!1),e;case 1:return e=ie(e.type,!0),e;default:return``}}function oe(e){if(e==null)return null;if(typeof e==`function`)return e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case E:return`Fragment`;case T:return`Portal`;case O:return`Profiler`;case D:return`StrictMode`;case M:return`Suspense`;case N:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case A:return(e.displayName||`Context`)+`.Consumer`;case k:return(e._context.displayName||`Context`)+`.Provider`;case j:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case P:return t=e.displayName||null,t===null?oe(e.type)||`Memo`:t;case F:t=e._payload,e=e._init;try{return oe(e(t))}catch{}}return null}function se(e){var t=e.type;switch(e.tag){case 24:return`Cache`;case 9:return(t.displayName||`Context`)+`.Consumer`;case 10:return(t._context.displayName||`Context`)+`.Provider`;case 18:return`DehydratedFragment`;case 11:return e=t.render,e=e.displayName||e.name||``,t.displayName||(e===``?`ForwardRef`:`ForwardRef(`+e+`)`);case 7:return`Fragment`;case 5:return t;case 4:return`Portal`;case 3:return`Root`;case 6:return`Text`;case 16:return oe(t);case 8:return t===D?`StrictMode`:`Mode`;case 22:return`Offscreen`;case 12:return`Profiler`;case 21:return`Scope`;case 13:return`Suspense`;case 19:return`SuspenseList`;case 25:return`TracingMarker`;case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t==`function`)return t.displayName||t.name||null;if(typeof t==`string`)return t}return null}function ce(e){switch(typeof e){case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function le(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function ue(e){var t=le(e)?`checked`:`value`,n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=``+e[t];if(!e.hasOwnProperty(t)&&n!==void 0&&typeof n.get==`function`&&typeof n.set==`function`){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){r=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function z(e){e._valueTracker||=ue(e)}function de(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=le(e)?e.checked?`true`:`false`:e.value),e=r,e===n?!1:(t.setValue(e),!0)}function B(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function fe(e,t){var n=t.checked;return R({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function pe(e,t){var n=t.defaultValue==null?``:t.defaultValue,r=t.checked==null?t.defaultChecked:t.checked;n=ce(t.value==null?n:t.value),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type===`checkbox`||t.type===`radio`?t.checked!=null:t.value!=null}}function me(e,t){t=t.checked,t!=null&&S(e,`checked`,t,!1)}function he(e,t){me(e,t);var n=ce(t.value),r=t.type;if(n!=null)r===`number`?(n===0&&e.value===``||e.value!=n)&&(e.value=``+n):e.value!==``+n&&(e.value=``+n);else if(r===`submit`||r===`reset`){e.removeAttribute(`value`);return}t.hasOwnProperty(`value`)?_e(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&_e(e,t.type,ce(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ge(e,t,n){if(t.hasOwnProperty(`value`)||t.hasOwnProperty(`defaultValue`)){var r=t.type;if(!(r!==`submit`&&r!==`reset`||t.value!==void 0&&t.value!==null))return;t=``+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==``&&(e.name=``),e.defaultChecked=!!e._wrapperState.initialChecked,n!==``&&(e.name=n)}function _e(e,t,n){(t!==`number`||B(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var ve=Array.isArray;function ye(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=U.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Te(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Ee={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},De=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(Ee).forEach(function(e){De.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Ee[t]=Ee[e]})});function Oe(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||Ee.hasOwnProperty(e)&&Ee[e]?(``+t).trim():t+`px`}function ke(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=Oe(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var Ae=R({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function je(e,t){if(t){if(Ae[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(r(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(r(60));if(typeof t.dangerouslySetInnerHTML!=`object`||!(`__html`in t.dangerouslySetInnerHTML))throw Error(r(61))}if(t.style!=null&&typeof t.style!=`object`)throw Error(r(62))}}function Me(e,t){if(e.indexOf(`-`)===-1)return typeof t.is==`string`;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var Ne=null;function Pe(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Fe=null,Ie=null,Le=null;function Re(e){if(e=ji(e)){if(typeof Fe!=`function`)throw Error(r(280));var t=e.stateNode;t&&(t=Ni(t),Fe(e.stateNode,e.type,t))}}function ze(e){Ie?Le?Le.push(e):Le=[e]:Ie=e}function Be(){if(Ie){var e=Ie,t=Le;if(Le=Ie=null,Re(e),t)for(e=0;e>>=0,e===0?32:31-(vt(e)/yt|0)|0}var xt=64,St=4194304;function Ct(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function wt(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~i;s===0?(a&=o,a!==0&&(r=Ct(a))):r=Ct(s)}else o=n&~i,o===0?a!==0&&(r=Ct(a)):r=Ct(o);if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&a&4194240))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function At(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-_t(t),e[t]=n}function jt(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=X),Jn=` `,Yn=!1;function Xn(e,t){switch(e){case`keyup`:return Wn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Zn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Qn=!1;function $n(e,t){switch(e){case`compositionend`:return Zn(t);case`keypress`:return t.which===32?(Yn=!0,Jn):null;case`textInput`:return e=t.data,e===Jn&&Yn?null:e;default:return null}}function er(e,t){if(Qn)return e===`compositionend`||!Gn&&Xn(e,t)?(e=hn(),mn=pn=fn=null,Qn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=xr(n)}}function Cr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Cr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function wr(){for(var e=window,t=B();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=B(e.document)}return t}function Tr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}function Er(e){var t=wr(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Cr(n.ownerDocument.documentElement,n)){if(r!==null&&Tr(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),`selectionStart`in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=Sr(n,a);var o=Sr(n,r);i&&o&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus==`function`&&n.focus(),n=0;n=document.documentMode,Or=null,kr=null,Ar=null,jr=!1;function Mr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;jr||Or==null||Or!==B(r)||(r=Or,`selectionStart`in r&&Tr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Ar&&br(Ar,r)||(Ar=r,r=ii(kr,`onSelect`),0Fi||(e.current=Pi[Fi],Pi[Fi]=null,Fi--)}function Ri(e,t){Fi++,Pi[Fi]=e.current,e.current=t}var zi={},Bi=Ii(zi),Vi=Ii(!1),Hi=zi;function Ui(e,t){var n=e.type.contextTypes;if(!n)return zi;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function Wi(e){return e=e.childContextTypes,e!=null}function Gi(){Li(Vi),Li(Bi)}function Ki(e,t,n){if(Bi.current!==zi)throw Error(r(168));Ri(Bi,t),Ri(Vi,n)}function qi(e,t,n){var i=e.stateNode;if(t=t.childContextTypes,typeof i.getChildContext!=`function`)return n;for(var a in i=i.getChildContext(),i)if(!(a in t))throw Error(r(108,se(e)||`Unknown`,a));return R({},n,i)}function Ji(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||zi,Hi=Bi.current,Ri(Bi,e),Ri(Vi,Vi.current),!0}function Yi(e,t,n){var i=e.stateNode;if(!i)throw Error(r(169));n?(e=qi(e,t,Hi),i.__reactInternalMemoizedMergedChildContext=e,Li(Vi),Li(Bi),Ri(Bi,e)):Li(Vi),Ri(Vi,n)}var Xi=null,Zi=!1,Qi=!1;function $i(e){Xi===null?Xi=[e]:Xi.push(e)}function ea(e){Zi=!0,$i(e)}function ta(){if(!Qi&&Xi!==null){Qi=!0;var e=0,t=Y;try{var n=Xi;for(Y=1;e>=o,i-=o,la=1<<32-_t(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(r,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(r,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(r,d),_a&&da(r,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),_a&&da(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return _a&&da(a,g),u}for(h=i(a,h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),_a&&da(a,g),u}function _(e,r,i,o){if(typeof i==`object`&&i&&i.type===E&&i.key===null&&(i=i.props.children),typeof i==`object`&&i){switch(i.$$typeof){case w:a:{for(var c=i.key,l=r;l!==null;){if(l.key===c){if(c=i.type,c===E){if(l.tag===7){n(e,l.sibling),r=a(l,i.props.children),r.return=e,e=r;break a}}else if(l.elementType===c||typeof c==`object`&&c&&c.$$typeof===F&&ja(c)===l.type){n(e,l.sibling),r=a(l,i.props),r.ref=ka(e,l,i),r.return=e,e=r;break a}n(e,l);break}else t(e,l);l=l.sibling}i.type===E?(r=Zl(i.props.children,e.mode,o,i.key),r.return=e,e=r):(o=Xl(i.type,i.key,i.props,null,e.mode,o),o.ref=ka(e,r,i),o.return=e,e=o)}return s(e);case T:a:{for(l=i.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===i.containerInfo&&r.stateNode.implementation===i.implementation){n(e,r.sibling),r=a(r,i.children||[]),r.return=e,e=r;break a}else{n(e,r);break}else t(e,r);r=r.sibling}r=eu(i,e.mode,o),r.return=e,e=r}return s(e);case F:return l=i._init,_(e,r,l(i._payload),o)}if(ve(i))return h(e,r,i,o);if(ee(i))return g(e,r,i,o);Aa(e,i)}return typeof i==`string`&&i!==``||typeof i==`number`?(i=``+i,r!==null&&r.tag===6?(n(e,r.sibling),r=a(r,i),r.return=e,e=r):(n(e,r),r=$l(i,e.mode,o),r.return=e,e=r),s(e)):n(e,r)}return _}var Na=Ma(!0),Pa=Ma(!1),Fa=Ii(null),Ia=null,La=null,Ra=null;function za(){Ra=La=Ia=null}function Ba(e){var t=Fa.current;Li(Fa),e._currentValue=t}function Va(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function Ha(e,t){Ia=e,Ra=La=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ms=!0),e.firstContext=null)}function Ua(e){var t=e._currentValue;if(Ra!==e)if(e={context:e,memoizedValue:t,next:null},La===null){if(Ia===null)throw Error(r(308));La=e,Ia.dependencies={lanes:0,firstContext:e}}else La=La.next=e;return t}var Wa=null;function Ga(e){Wa===null?Wa=[e]:Wa.push(e)}function Ka(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,Ga(t)):(n.next=i.next,i.next=n),t.interleaved=n,qa(e,r)}function qa(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Ja=!1;function Ya(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Xa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Za(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Qa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,$&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,qa(e,n)}return i=r.interleaved,i===null?(t.next=t,Ga(r)):(t.next=i.next,i.next=t),r.interleaved=t,qa(e,n)}function $a(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194240)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Mt(e,n)}}function eo(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function to(e,t,n,r){var i=e.updateQueue;Ja=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane,p=s.eventTime;if((r&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});a:{var m=e,h=s;switch(f=t,p=n,h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(p,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(p,d,f):m,f==null)break a;d=R({},d,f);break a;case 2:Ja=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=i.effects,f===null?i.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;f=s,s=f.next,f.next=null,i.lastBaseUpdate=f,i.shared.pending=null}}while(1);if(u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,t=i.shared.interleaved,t!==null){i=t;do o|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);Jc|=o,e.lanes=o,e.memoizedState=d}}function no(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=vo.transition;vo.transition={};try{e(!1),t()}finally{Y=n,vo.transition=r}}function as(){return Mo().memoizedState}function os(e,t,n){var r=pl(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},cs(e))ls(t,n);else if(n=Ka(e,t,n,r),n!==null){var i=fl();ml(n,e,r,i),us(n,t,r)}}function ss(e,t,n){var r=pl(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(cs(e))ls(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Z(s,o)){var c=t.interleaved;c===null?(i.next=i,Ga(t)):(i.next=c.next,c.next=i),t.interleaved=i;return}}catch{}n=Ka(e,t,i,r),n!==null&&(i=fl(),ml(n,e,r,i),us(n,t,r))}}function cs(e){var t=e.alternate;return e===bo||t!==null&&t===bo}function ls(e,t){wo=Co=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function us(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Mt(e,n)}}var ds={readContext:Ua,useCallback:Do,useContext:Do,useEffect:Do,useImperativeHandle:Do,useInsertionEffect:Do,useLayoutEffect:Do,useMemo:Do,useReducer:Do,useRef:Do,useState:Do,useDebugValue:Do,useDeferredValue:Do,useTransition:Do,useMutableSource:Do,useSyncExternalStore:Do,useId:Do,unstable_isNewReconciler:!1},fs={readContext:Ua,useCallback:function(e,t){return jo().memoizedState=[e,t===void 0?null:t],e},useContext:Ua,useEffect:Jo,useImperativeHandle:function(e,t,n){return n=n==null?null:n.concat([e]),Ko(4194308,4,Qo.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ko(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ko(4,2,e,t)},useMemo:function(e,t){var n=jo();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=jo();return t=n===void 0?t:n(t),r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=os.bind(null,bo,e),[r.memoizedState,e]},useRef:function(e){var t=jo();return e={current:e},t.memoizedState=e},useState:Uo,useDebugValue:es,useDeferredValue:function(e){return jo().memoizedState=e},useTransition:function(){var e=Uo(!1),t=e[0];return e=is.bind(null,e[1]),jo().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var i=bo,a=jo();if(_a){if(n===void 0)throw Error(r(407));n=n()}else{if(n=t(),Vc===null)throw Error(r(349));yo&30||Ro(i,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,Jo(Bo.bind(null,i,o,e),[e]),i.flags|=2048,Wo(9,zo.bind(null,i,o,n,t),void 0,null),n},useId:function(){var e=jo(),t=Vc.identifierPrefix;if(_a){var n=ua,r=la;n=(r&~(1<<32-_t(r)-1)).toString(32)+n,t=`:`+t+`R`+n,n=To++,0<\/script>`,e=e.removeChild(e.firstChild)):typeof i.is==`string`?e=c.createElement(n,{is:i.is}):(e=c.createElement(n),n===`select`&&(c=e,i.multiple?c.multiple=!0:i.size&&(c.size=i.size))):e=c.createElementNS(e,n),e[wi]=t,e[Ti]=i,nc(e,t,!1,!1),t.stateNode=e;a:{switch(c=Me(n,i),n){case`dialog`:Zr(`cancel`,e),Zr(`close`,e),o=i;break;case`iframe`:case`object`:case`embed`:Zr(`load`,e),o=i;break;case`video`:case`audio`:for(o=0;oel&&(t.flags|=128,i=!0,ac(s,!1),t.lanes=4194304)}else{if(!i)if(e=mo(c),e!==null){if(t.flags|=128,i=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),ac(s,!0),s.tail===null&&s.tailMode===`hidden`&&!c.alternate&&!_a)return oc(t),null}else 2*st()-s.renderingStartTime>el&&n!==1073741824&&(t.flags|=128,i=!0,ac(s,!1),t.lanes=4194304);s.isBackwards?(c.sibling=t.child,t.child=c):(n=s.last,n===null?t.child=c:n.sibling=c,s.last=c)}return s.tail===null?(oc(t),null):(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=st(),t.sibling=null,n=po.current,Ri(po,i?n&1|2:n&1),t);case 22:case 23:return wl(),i=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==i&&(t.flags|=8192),i&&t.mode&1?Wc&1073741824&&(oc(t),t.subtreeFlags&6&&(t.flags|=8192)):oc(t),null;case 24:return null;case 25:return null}throw Error(r(156,t.tag))}function cc(e,t){switch(ma(t),t.tag){case 1:return Wi(t.type)&&Gi(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return lo(),Li(Vi),Li(Bi),go(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return fo(t),null;case 13:if(Li(po),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(r(340));Ea()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Li(po),null;case 4:return lo(),null;case 10:return Ba(t.type._context),null;case 22:case 23:return wl(),null;case 24:return null;default:return null}}var lc=!1,uc=!1,dc=typeof WeakSet==`function`?WeakSet:Set,Q=null;function fc(e,t){var n=e.ref;if(n!==null)if(typeof n==`function`)try{n(null)}catch(n){Rl(e,t,n)}else n.current=null}function pc(e,t,n){try{n()}catch(n){Rl(e,t,n)}}var mc=!1;function hc(e,t){if(fi=an,e=wr(),Tr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var i=n.getSelection&&n.getSelection();if(i&&i.rangeCount!==0){n=i.anchorNode;var a=i.anchorOffset,o=i.focusNode;i=i.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||i!==0&&f.nodeType!==3||(l=s+i),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===i&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(pi={focusedElem:e,selectionRange:n},an=!1,Q=t;Q!==null;)if(t=Q,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,Q=e;else for(;Q!==null;){t=Q;try{var h=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(h!==null){var g=h.memoizedProps,_=h.memoizedState,v=t.stateNode;v.__reactInternalSnapshotBeforeUpdate=v.getSnapshotBeforeUpdate(t.elementType===t.type?g:hs(t.type,g),_)}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent=``:y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(r(163))}}catch(e){Rl(t,t.return,e)}if(e=t.sibling,e!==null){e.return=t.return,Q=e;break}Q=t.return}return h=mc,mc=!1,h}function gc(e,t,n){var r=t.updateQueue;if(r=r===null?null:r.lastEffect,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&pc(t,n,a)}i=i.next}while(i!==r)}}function _c(e,t){if(t=t.updateQueue,t=t===null?null:t.lastEffect,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function vc(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t==`function`?t(e):t.current=e}}function yc(e){var t=e.alternate;t!==null&&(e.alternate=null,yc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[wi],delete t[Ti],delete t[Di],delete t[Oi],delete t[ki])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function bc(e){return e.tag===5||e.tag===3||e.tag===4}function xc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||bc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Sc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=di));else if(r!==4&&(e=e.child,e!==null))for(Sc(e,t,n),e=e.sibling;e!==null;)Sc(e,t,n),e=e.sibling}function Cc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Cc(e,t,n),e=e.sibling;e!==null;)Cc(e,t,n),e=e.sibling}var wc=null,Tc=!1;function Ec(e,t,n){for(n=n.child;n!==null;)Dc(e,t,n),n=n.sibling}function Dc(e,t,n){if(ht&&typeof ht.onCommitFiberUnmount==`function`)try{ht.onCommitFiberUnmount(mt,n)}catch{}switch(n.tag){case 5:uc||fc(n,t);case 6:var r=wc,i=Tc;wc=null,Ec(e,t,n),wc=r,Tc=i,wc!==null&&(Tc?(e=wc,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):wc.removeChild(n.stateNode));break;case 18:wc!==null&&(Tc?(e=wc,n=n.stateNode,e.nodeType===8?bi(e.parentNode,n):e.nodeType===1&&bi(e,n),nn(e)):bi(wc,n.stateNode));break;case 4:r=wc,i=Tc,wc=n.stateNode.containerInfo,Tc=!0,Ec(e,t,n),wc=r,Tc=i;break;case 0:case 11:case 14:case 15:if(!uc&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&pc(n,t,o),i=i.next}while(i!==r)}Ec(e,t,n);break;case 1:if(!uc&&(fc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(e){Rl(n,t,e)}Ec(e,t,n);break;case 21:Ec(e,t,n);break;case 22:n.mode&1?(uc=(r=uc)||n.memoizedState!==null,Ec(e,t,n),uc=r):Ec(e,t,n);break;default:Ec(e,t,n)}}function Oc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new dc),t.forEach(function(t){var r=Hl.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))})}}function kc(e,t){var n=t.deletions;if(n!==null)for(var i=0;ia&&(a=s),i&=~o}if(i=a,i=st()-i,i=(120>i?120:480>i?480:1080>i?1080:1920>i?1920:3e3>i?3e3:4320>i?4320:1960*Lc(i/1960))-i,10e?16:e,ol===null)var i=!1;else{if(e=ol,ol=null,sl=0,$&6)throw Error(r(331));var a=$;for($|=4,Q=e.current;Q!==null;){var o=Q,s=o.child;if(Q.flags&16){var c=o.deletions;if(c!==null){for(var l=0;lst()-$c?Tl(e,0):Xc|=n),hl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=St,St<<=1,!(St&130023424)&&(St=4194304)):t=1);var n=fl();e=qa(e,t),e!==null&&(At(e,t,n),hl(e,n))}function Vl(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bl(e,n)}function Hl(e,t){var n=0;switch(e.tag){case 13:var i=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:i=e.stateNode;break;default:throw Error(r(314))}i!==null&&i.delete(t),Bl(e,n)}var Ul=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Vi.current)Ms=!0;else{if((e.lanes&n)===0&&!(t.flags&128))return Ms=!1,tc(e,t,n);Ms=!!(e.flags&131072)}else Ms=!1,_a&&t.flags&1048576&&fa(t,aa,t.index);switch(t.lanes=0,t.tag){case 2:var i=t.type;$s(e,t),e=t.pendingProps;var a=Ui(t,Bi.current);Ha(t,n),a=ko(null,t,i,e,a,n);var o=Ao();return t.flags|=1,typeof a==`object`&&a&&typeof a.render==`function`&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Wi(i)?(o=!0,Ji(t)):o=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,Ya(t),a.updater=_s,t.stateNode=a,a._reactInternals=t,xs(t,i,e,n),t=Vs(null,t,i,!0,o,n)):(t.tag=0,_a&&o&&pa(t),Ns(null,t,a,n),t=t.child),t;case 16:i=t.elementType;a:{switch($s(e,t),e=t.pendingProps,a=i._init,i=a(i._payload),t.type=i,a=t.tag=Jl(i),e=hs(i,e),a){case 0:t=zs(null,t,i,e,n);break a;case 1:t=Bs(null,t,i,e,n);break a;case 11:t=Ps(null,t,i,e,n);break a;case 14:t=Fs(null,t,i,hs(i.type,e),n);break a}throw Error(r(306,i,``))}return t;case 0:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),zs(e,t,i,a,n);case 1:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),Bs(e,t,i,a,n);case 3:a:{if(Hs(t),e===null)throw Error(r(387));i=t.pendingProps,o=t.memoizedState,a=o.element,Xa(e,t),to(t,i,null,n);var s=t.memoizedState;if(i=s.element,o.isDehydrated)if(o={element:i,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){a=Ss(Error(r(423)),t),t=Us(e,t,i,n,a);break a}else if(i!==a){a=Ss(Error(r(424)),t),t=Us(e,t,i,n,a);break a}else for(ga=xi(t.stateNode.containerInfo.firstChild),ha=t,_a=!0,va=null,n=Pa(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Ea(),i===a){t=ec(e,t,n);break a}Ns(e,t,i,n)}t=t.child}return t;case 5:return uo(t),e===null&&Sa(t),i=t.type,a=t.pendingProps,o=e===null?null:e.memoizedProps,s=a.children,mi(i,a)?s=null:o!==null&&mi(i,o)&&(t.flags|=32),Rs(e,t),Ns(e,t,s,n),t.child;case 6:return e===null&&Sa(t),null;case 13:return Ks(e,t,n);case 4:return co(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=Na(t,null,i,n):Ns(e,t,i,n),t.child;case 11:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),Ps(e,t,i,a,n);case 7:return Ns(e,t,t.pendingProps,n),t.child;case 8:return Ns(e,t,t.pendingProps.children,n),t.child;case 12:return Ns(e,t,t.pendingProps.children,n),t.child;case 10:a:{if(i=t.type._context,a=t.pendingProps,o=t.memoizedProps,s=a.value,Ri(Fa,i._currentValue),i._currentValue=s,o!==null)if(Z(o.value,s)){if(o.children===a.children&&!Vi.current){t=ec(e,t,n);break a}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){s=o.child;for(var l=c.firstContext;l!==null;){if(l.context===i){if(o.tag===1){l=Za(-1,n&-n),l.tag=2;var u=o.updateQueue;if(u!==null){u=u.shared;var d=u.pending;d===null?l.next=l:(l.next=d.next,d.next=l),u.pending=l}}o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),Va(o.return,n,t),c.lanes|=n;break}l=l.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(r(341));s.lanes|=n,c=s.alternate,c!==null&&(c.lanes|=n),Va(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}Ns(e,t,a.children,n),t=t.child}return t;case 9:return a=t.type,i=t.pendingProps.children,Ha(t,n),a=Ua(a),i=i(a),t.flags|=1,Ns(e,t,i,n),t.child;case 14:return i=t.type,a=hs(i,t.pendingProps),a=hs(i.type,a),Fs(e,t,i,a,n);case 15:return Is(e,t,t.type,t.pendingProps,n);case 17:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),$s(e,t),t.tag=1,Wi(i)?(e=!0,Ji(t)):e=!1,Ha(t,n),ys(t,i,a),xs(t,i,a,n),Vs(null,t,i,!0,e,n);case 19:return Qs(e,t,n);case 22:return Ls(e,t,n)}throw Error(r(156,t.tag))};function Wl(e,t){return rt(e,t)}function Gl(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Kl(e,t,n,r){return new Gl(e,t,n,r)}function ql(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Jl(e){if(typeof e==`function`)return+!!ql(e);if(e!=null){if(e=e.$$typeof,e===j)return 11;if(e===P)return 14}return 2}function Yl(e,t){var n=e.alternate;return n===null?(n=Kl(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Xl(e,t,n,i,a,o){var s=2;if(i=e,typeof e==`function`)ql(e)&&(s=1);else if(typeof e==`string`)s=5;else a:switch(e){case E:return Zl(n.children,a,o,t);case D:s=8,a|=8;break;case O:return e=Kl(12,n,t,a|2),e.elementType=O,e.lanes=o,e;case M:return e=Kl(13,n,t,a),e.elementType=M,e.lanes=o,e;case N:return e=Kl(19,n,t,a),e.elementType=N,e.lanes=o,e;case I:return Ql(n,a,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case k:s=10;break a;case A:s=9;break a;case j:s=11;break a;case P:s=14;break a;case F:s=16,i=null;break a}throw Error(r(130,e==null?e:typeof e,``))}return t=Kl(s,n,t,a),t.elementType=e,t.type=i,t.lanes=o,t}function Zl(e,t,n,r){return e=Kl(7,e,r,t),e.lanes=n,e}function Ql(e,t,n,r){return e=Kl(22,e,r,t),e.elementType=I,e.lanes=n,e.stateNode={isHidden:!1},e}function $l(e,t,n){return e=Kl(6,e,null,t),e.lanes=n,e}function eu(e,t,n){return t=Kl(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function tu(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=kt(0),this.expirationTimes=kt(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=kt(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function nu(e,t,n,r,i,a,o,s,c){return e=new tu(e,t,n,s,c),t===1?(t=1,!0===a&&(t|=8)):t=0,a=Kl(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ya(a),e}function ru(e,t,n){var r=3{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=m();e.createRoot=t.createRoot,e.hydrateRoot=t.hydrateRoot})),g=c(u()),_=c(h(),1),v=class extends Error{status;constructor(e,t){super(t),this.status=e}};async function y(e,t={}){let n=typeof FormData<`u`&&t.body instanceof FormData,r=await fetch(e,{credentials:`same-origin`,headers:n?t.headers:{"Content-Type":`application/json`,...t.headers??{}},...t}),i=await r.text(),a=i?JSON.parse(i):null;if(!r.ok){let e=a?.error||a?.Error||a?.message||r.statusText;throw new v(r.status,e)}return a}function b(e){return e instanceof Error?e.message:String(e)}var x={session:()=>y(`/api/session`),login:e=>y(`/api/login`,{method:`POST`,body:JSON.stringify({secret:e})}),logout:()=>y(`/api/logout`,{method:`POST`,body:`{}`}),accounts:e=>y(`/api/accounts?${e.toString()}`),account:e=>y(`/api/accounts/${e}`),channels:e=>y(`/api/channels?${e.toString()}`),channel:e=>y(`/api/channels/${e}`),messages:e=>y(`/api/messages?${e.toString()}`),message:(e,t)=>y(`/api/messages/detail?${new URLSearchParams({owner_user_id:String(e),msg_id:String(t)}).toString()}`),groupMessages:e=>y(`/api/messages/groups?${e.toString()}`),groupMessage:(e,t)=>y(`/api/messages/groups/detail?${new URLSearchParams({channel_id:String(e),msg_id:String(t)}).toString()}`),gifts:()=>y(`/api/gifts`),giftAnimation:e=>y(`/api/gifts/${e}/animation`),giftCollectibles:e=>y(`/api/gifts/${e}/collectibles`),giftCollectibleAnimation:(e,t,n)=>y(`/api/gifts/${e}/collectibles/${t}/${n}/animation`),importGift:e=>y(`/api/actions/import-gift`,{method:`POST`,body:e}),publishGiftCollectibles:(e,t)=>y(`/api/actions/publish-gift-collectibles?gift_id=${e}`,{method:`POST`,body:t}),action:(e,t)=>y(e,{method:`POST`,body:JSON.stringify(t)})},S=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),C=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),w={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},T=(0,g.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>(0,g.createElement)(`svg`,{ref:c,...w,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:C(`lucide`,i),...s},[...o.map(([e,t])=>(0,g.createElement)(e,t)),...Array.isArray(a)?a:[a]])),E=(e,t)=>{let n=(0,g.forwardRef)(({className:n,...r},i)=>(0,g.createElement)(T,{ref:i,iconNode:t,className:C(`lucide-${S(e)}`,n),...r}));return n.displayName=`${e}`,n},D=E(`BadgeCheck`,[[`path`,{d:`M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z`,key:`3c2336`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),O=E(`CircleAlert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]),k=E(`CircleCheck`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),A=E(`LoaderCircle`,[[`path`,{d:`M21 12a9 9 0 1 1-6.219-8.56`,key:`13zald`}]]),j=E(`Sparkles`,[[`path`,{d:`M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z`,key:`4pj2yx`}],[`path`,{d:`M20 3v4`,key:`1olli1`}],[`path`,{d:`M22 5h-4`,key:`1gvqau`}],[`path`,{d:`M4 17v2`,key:`vumght`}],[`path`,{d:`M5 18H3`,key:`zchphs`}]]),M=E(`ArrowLeft`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),N=E(`Cable`,[[`path`,{d:`M17 21v-2a1 1 0 0 1-1-1v-1a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1`,key:`10bnsj`}],[`path`,{d:`M19 15V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V9`,key:`1eqmu1`}],[`path`,{d:`M21 21v-2h-4`,key:`14zm7j`}],[`path`,{d:`M3 5h4V3`,key:`z442eg`}],[`path`,{d:`M7 5a1 1 0 0 1 1 1v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1V3`,key:`ebdjd7`}]]),P=E(`Check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),F=E(`ChevronDown`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]),I=E(`ChevronRight`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),L=E(`Clock3`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`polyline`,{points:`12 6 12 12 16.5 12`,key:`1aq6pp`}]]),ee=E(`Database`,[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 21 19V5`,key:`1wlel7`}],[`path`,{d:`M3 12A9 3 0 0 0 21 12`,key:`mv7ke4`}]]),R=E(`FileJson2`,[[`path`,{d:`M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4`,key:`1pf5j1`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M4 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`fq0c9t`}],[`path`,{d:`M8 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`4gibmv`}]]),te=E(`FileJson`,[[`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`,key:`1rqfz7`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`1oajmo`}],[`path`,{d:`M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`mpwhp6`}]]),ne=E(`Gem`,[[`path`,{d:`M6 3h12l4 6-10 13L2 9Z`,key:`1pcd5k`}],[`path`,{d:`M11 3 8 9l4 13 4-13-3-6`,key:`1fcu3u`}],[`path`,{d:`M2 9h20`,key:`16fsjt`}]]),re=E(`Gift`,[[`rect`,{x:`3`,y:`8`,width:`18`,height:`4`,rx:`1`,key:`bkv52`}],[`path`,{d:`M12 8v13`,key:`1c76mn`}],[`path`,{d:`M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7`,key:`6wjy6b`}],[`path`,{d:`M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5`,key:`1ihvrl`}]]),ie=E(`History`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}],[`path`,{d:`M12 7v5l4 2`,key:`1fdv2h`}]]),ae=E(`KeyRound`,[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]),oe=E(`LayoutDashboard`,[[`rect`,{width:`7`,height:`9`,x:`3`,y:`3`,rx:`1`,key:`10lvy0`}],[`rect`,{width:`7`,height:`5`,x:`14`,y:`3`,rx:`1`,key:`16une8`}],[`rect`,{width:`7`,height:`9`,x:`14`,y:`12`,rx:`1`,key:`1hutg5`}],[`rect`,{width:`7`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`ldoo1y`}]]),se=E(`LogOut`,[[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}],[`polyline`,{points:`16 17 21 12 16 7`,key:`1gabdz`}],[`line`,{x1:`21`,x2:`9`,y1:`12`,y2:`12`,key:`1uyos4`}]]),ce=E(`MessageSquareText`,[[`path`,{d:`M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z`,key:`1lielz`}],[`path`,{d:`M13 8H7`,key:`14i4kc`}],[`path`,{d:`M17 12H7`,key:`16if0g`}]]),le=E(`Pause`,[[`rect`,{x:`14`,y:`4`,width:`4`,height:`16`,rx:`1`,key:`zuxfzm`}],[`rect`,{x:`6`,y:`4`,width:`4`,height:`16`,rx:`1`,key:`1okwgv`}]]),ue=E(`Play`,[[`polygon`,{points:`6 3 20 12 6 21 6 3`,key:`1oa8hb`}]]),z=E(`Plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]),de=E(`RefreshCw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),B=E(`Search`,[[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}],[`path`,{d:`m21 21-4.3-4.3`,key:`1qie3q`}]]),fe=E(`Server`,[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]]),pe=E(`ShieldCheck`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),me=E(`Shield`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]),he=E(`Star`,[[`path`,{d:`M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z`,key:`r04s7s`}]]),ge=E(`Trash2`,[[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6`,key:`4alrt4`}],[`path`,{d:`M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2`,key:`v07s0e`}],[`line`,{x1:`10`,x2:`10`,y1:`11`,y2:`17`,key:`1uufr5`}],[`line`,{x1:`14`,x2:`14`,y1:`11`,y2:`17`,key:`xtxkd`}]]),_e=E(`Upload`,[[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`polyline`,{points:`17 8 12 3 7 8`,key:`t8dd8p`}],[`line`,{x1:`12`,x2:`12`,y1:`3`,y2:`15`,key:`widbto`}]]),ve=E(`Users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`path`,{d:`M16 3.13a4 4 0 0 1 0 7.75`,key:`1da9ce`}]]),ye=E(`X`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),be=o((e=>{var t=u(),n=Symbol.for(`react.element`),r=Symbol.for(`react.fragment`),i=Object.prototype.hasOwnProperty,a=t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,o={key:!0,ref:!0,__self:!0,__source:!0};function s(e,t,r){var s,c={},l=null,u=null;for(s in r!==void 0&&(l=``+r),t.key!==void 0&&(l=``+t.key),t.ref!==void 0&&(u=t.ref),t)i.call(t,s)&&!o.hasOwnProperty(s)&&(c[s]=t[s]);if(e&&e.defaultProps)for(s in t=e.defaultProps,t)c[s]===void 0&&(c[s]=t[s]);return{$$typeof:n,type:e,key:l,ref:u,props:c,_owner:a.current}}e.Fragment=r,e.jsx=s,e.jsxs=s})),V=o(((e,t)=>{t.exports=be()}))(),H=`telesrv.admin.lang`,xe={en:{"app.adminConsole":`Admin Console`,"app.localAccess":`Local access`,"app.title":`telesrv admin`,"common.actions":`Actions`,"common.admins":`Admins`,"common.backToList":`Back to list`,"common.channel":`Channel`,"common.channelOrGroup":`Channel / Group`,"common.clear":`Clear`,"common.close":`Close`,"common.count":`Count`,"common.deleted":`Deleted`,"common.detail":`Details`,"common.device":`Device`,"common.disabled":`Disabled`,"common.enabled":`Enabled`,"common.fromPeer":`From Peer`,"common.group":`Group`,"common.id":`ID`,"common.limit":`Limit`,"common.loading":`Loading`,"common.member":`Member`,"common.members":`Members`,"common.messageId":`Message ID`,"common.name":`Name`,"common.no":`No`,"common.noResults":`No results`,"common.none":`None`,"common.normal":`Normal`,"common.operations":`Operations`,"common.owner":`Owner`,"common.platform":`Platform`,"common.refresh":`Refresh`,"common.search":`Search`,"common.sender":`Sender`,"common.status":`Status`,"common.survived":`Live`,"common.time":`Time`,"common.type":`Type`,"common.updatedAt":`Updated`,"common.username":`Username`,"common.valid":`Valid`,"common.verified":`Verified`,"common.views":`Views`,"common.yes":`Yes`,"route.accounts":`Accounts`,"route.accountsSubtitle":`Console / Accounts`,"route.channels":`Supergroups and Channels`,"route.channelsSubtitle":`Console / Channels`,"route.dashboard":`Operations Console`,"route.dashboardSubtitle":`Console / Overview`,"route.messages":`Message Audit`,"route.messagesSubtitle":`Console / Messages`,"route.gifts":`Star Gifts`,"route.giftsSubtitle":`Console / Star Gifts`,"layout.navigation":`Navigation`,"layout.primaryNav":`Primary navigation`,"layout.dashboard":`Overview`,"layout.accounts":`Accounts`,"layout.channels":`Supergroups / Channels`,"layout.messages":`Messages`,"layout.gifts":`Star Gifts`,"layout.privateMessages":`Private`,"layout.groupMessages":`Groups`,"layout.runtime":`Runtime`,"layout.adminBackend":`Admin backend`,"layout.ready":`Ready`,"layout.pgRead":`PG read`,"layout.readOnly":`Read-only`,"layout.writeOps":`Write operations`,"layout.dryRun":`Dry-run`,"layout.actor":`Actor: {actor}`,"layout.logout":`Log out`,"language.en":`EN`,"language.zh":`中文`,"language.ru":`RU`,"login.heading":`Operations Admin`,"login.body":`Enter credentials to open the console.`,"login.secret":`Admin password or token`,"login.submit":`Log in`,"login.submitting":`Logging in`,"dashboard.eyebrow":`Runtime Overview`,"dashboard.title":`Console Overview`,"dashboard.readPath":`Read path`,"dashboard.readPathValue":`PG read-only`,"dashboard.writePath":`Write path`,"dashboard.executionPolicy":`Execution policy`,"dashboard.dryRunFirst":`Dry-run first`,"dashboard.accountsText":`Account status, premium, verification, sessions.`,"dashboard.channelsText":`Public entities, member counts, verification state.`,"dashboard.messagesText":`Message boxes, updates, outbox state.`,"dashboard.strip.dryRun":`All dangerous actions start with dry-run`,"dashboard.strip.token":`Browser never stores internal tokens`,"dashboard.strip.pagination":`Lists use cursor pagination`,"dashboard.strip.snapshot":`Detail pages retain raw state snapshots`,"account.pageTitle":`Accounts`,"account.queryResults":`Search results`,"account.recentActive":`Recently active accounts`,"account.currentPage":`Accounts on page`,"account.onlineDevices":`Online device records`,"account.premium":`Premium`,"account.frozen":`Frozen`,"account.searchPlaceholder":`User ID / phone / username`,"account.userID":`User ID`,"account.phone":`Phone`,"account.lastActive":`Last active`,"account.notVerified":`Not verified`,"account.notPremium":`Not premium`,"account.premiumUntil":`Premium expires`,"account.starsBalance":`Stars balance`,"account.startingGrantApplied":`initial grant applied`,"account.startingGrantPending":`initial grant pending`,"account.activeSessions":`Authorized devices`,"account.accountFlags":`Account flags`,"account.restriction":`Restriction`,"account.restricted":`Restricted`,"account.createdAt":`Created`,"account.detailTitle":`Account #{id}`,"account.profile":`Account Profile`,"account.loadingDetail":`Loading account detail`,"account.waitingData":`Waiting for data`,"account.noUsername":`No username`,"account.noPhone":`No phone`,"account.accountFrozen":`Account frozen`,"account.accountActive":`Account active`,"account.authorizationsTitle":`Authorized Devices`,"account.authorizationsCount":`{count} authorizations`,"account.recentAdminOps":`Recent Admin Actions`,"account.recent30Audit":`Last 30 audit rows`,"account.actionDock":`Account Actions`,"account.freezeAccount":`Freeze account`,"account.updateFreeze":`Update freeze`,"account.unfreezeAccount":`Unfreeze account`,"account.freezeSince":`Frozen since`,"account.freezeUntil":`Appeal deadline`,"account.freezeUntilAria":`Freeze appeal deadline`,"account.freezeAppealURL":`Appeal URL`,"account.freezeAppealURLAria":`Freeze appeal URL`,"account.premiumMonths":`Premium duration (months)`,"account.premiumMonthsAria":`Set premium duration in months`,"account.setPremium":`Set premium`,"account.clearPremium":`Clear premium`,"account.starsAmount":`Stars to grant`,"account.starsAmountAria":`Set Stars amount to grant`,"account.grantStars":`Grant Stars`,"account.setVerified":`Set verified`,"account.clearVerified":`Clear verified`,"channel.pageTitle":`Supergroups and Channels`,"channel.recentUpdated":`Recently updated`,"channel.currentPage":`Entities on page`,"channel.megagroups":`Supergroups`,"channel.broadcasts":`Channels`,"channel.verifiedCount":`Verified`,"channel.searchPlaceholder":`Channel ID / username / title`,"channel.channelID":`Channel ID`,"channel.kind":`Kind`,"channel.title":`Title`,"channel.pts":`PTS`,"channel.detailProfile":`Channel Profile`,"channel.loadingDetail":`Loading channel detail`,"channel.creator":`Creator {id}`,"channel.governance":`Moderation`,"channel.governanceValue":`Banned {banned} / Kicked {kicked}`,"channel.flags":`Channel flags`,"channel.rawRow":`Channel Raw Row`,"channel.rawRowText":`Database read-only snapshot`,"channel.actionDock":`Channel Actions`,"channel.setVerified":`Set verified`,"channel.clearVerified":`Clear verified`,"channel.kind.broadcast":`Channel`,"channel.kind.forum":`Supergroup / Forum`,"channel.kind.megagroup":`Supergroup`,"channel.kind.generic":`Channel / Group`,"messages.privateTitle":`Private Messages`,"messages.privateEyebrow":`Private message boxes`,"messages.groupTitle":`Group Messages`,"messages.groupEyebrow":`Supergroup / channel messages`,"messages.selectPrivatePeers":`Search and select the owner user and peer user first`,"messages.selectChannel":`Search and select a supergroup or channel first`,"messages.ownerUser":`Owner user`,"messages.peerUser":`Peer user`,"messages.beforeDatePlaceholder":`before_date cursor`,"messages.beforeIDPlaceholder":`before_msg_id cursor`,"messages.limitPlaceholder":`limit <= 100`,"messages.searchMessages":`Search messages`,"messages.nextPage":`Next page`,"messages.currentPage":`Messages on page`,"messages.deleted":`Deleted`,"messages.outgoing":`Outgoing`,"messages.incoming":`Incoming`,"messages.ownerPeer":`Owner / Peer`,"messages.deleteSelected":`Delete selected messages`,"messages.idsPlaceholder":`Message IDs, comma separated`,"messages.revoke":`Revoke for both sides`,"messages.previewDelete":`Dry-run delete`,"messages.clearHistory":`Clear private history`,"messages.maxIDPlaceholder":`max_id cutoff`,"messages.maxBatchesPlaceholder":`max_batches`,"messages.justClear":`Clear only this side`,"messages.previewClearHistory":`Dry-run clear history`,"messages.direction":`Direction`,"messages.body":`Body`,"messages.privateDetailTitle":`Message #{id}`,"messages.detailEyebrow":`Message Detail`,"messages.backPrivate":`Back to private messages`,"messages.backGroup":`Back to group messages`,"messages.ownerPeerTitle":`Owner {owner} · Peer {peer}`,"messages.senderSubtitle":`Sender {sender} · {date}`,"messages.boxID":`Message box ID`,"messages.privateMessageID":`Private message ID`,"messages.messageSender":`Message sender`,"messages.messageBox":`Message Box`,"messages.dialogRow":`Dialog Row`,"messages.privateRow":`Private Message Row`,"messages.channelMessageRow":`Channel Message Row`,"messages.channelRow":`Channel Row`,"messages.userUpdateEvents":`Update Events`,"messages.channelUpdateEvents":`Channel Update Events`,"messages.eventJson":`Event JSON`,"messages.dispatchOutbox":`Dispatch Queue`,"messages.messageBoxesSnapshot":`message_boxes read-only snapshot`,"messages.dialogSnapshot":`dialogs read-only snapshot`,"messages.privateSnapshot":`private_messages read-only snapshot`,"messages.channelMessagesSnapshot":`channel_messages read-only snapshot`,"messages.channelSnapshot":`channels read-only snapshot`,"messages.userEventsSource":`durable user_update_events`,"messages.channelEventsSource":`durable channel_update_events`,"messages.outboxSource":`online/offline dispatch_outbox`,"messages.attempts":`Attempts`,"messages.deleteThis":`Delete this message`,"messages.groupDetailTitle":`Group Message #{id}`,"messages.channelGroupTitle":`Channel / Group {id}`,"messages.mediaCount":`With media`,"messages.channelPosts":`Channel posts`,"messages.channelGroup":`Channel / Group`,"messages.pinned":`Pinned`,"messages.channelPost":`Channel post`,"gifts.pageTitle":`Star Gift Catalog`,"gifts.eyebrow":`Catalog, immutable revisions and animation assets`,"gifts.total":`Catalog entries`,"gifts.enabled":`Enabled`,"gifts.received":`Received gifts`,"gifts.formats":`Accepted formats`,"gifts.add":`Add gift`,"gifts.searchPlaceholder":`Search gift ID, title or format`,"gifts.listSummary":`Showing {shown} of {total}`,"gifts.idRevision":`ID / Revision`,"gifts.price":`Price / Conversion`,"gifts.importTitle":`Import a Star Gift`,"gifts.importEyebrow":`Gift catalog operation`,"gifts.newRevision":`Create revision for gift #{id}`,"gifts.importHint":`Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.`,"gifts.animation":`Animation file`,"gifts.filePrompt":`Drop or choose a TGS / Lottie file`,"gifts.fileHint":`TGS, JSON or Lottie · validated before import`,"gifts.chooseFile":`Choose file`,"gifts.changeFile":`Change file`,"gifts.title":`Display title`,"gifts.titlePlaceholder":`e.g. Celebration Star`,"gifts.stars":`Price in Stars`,"gifts.convertStars":`Conversion Stars`,"gifts.sortOrder":`Sort order`,"gifts.reason":`Audit reason`,"gifts.reasonPlaceholder":`Briefly describe why this gift is being imported`,"gifts.enableAfterImport":`Enable after import`,"gifts.validate":`Dry-run validation`,"gifts.confirmImport":`Confirm import`,"gifts.stepDetails":`File and details`,"gifts.stepValidate":`Dry-run validation`,"gifts.stepImport":`Confirm import`,"gifts.fileRequired":`Choose a TGS or Lottie file first`,"gifts.source":`Source`,"gifts.replace":`New revision`,"gifts.disable":`Disable`,"gifts.enable":`Enable`,"gifts.empty":`No Star Gifts have been imported.`,"gifts.emptyHint":`Import the first animation above to build the gift catalog.`,"gifts.validationReady":`Validation passed`,"gifts.validationHint":`Review the normalized metadata, then confirm the import.`,"gifts.confirmState":`Apply the validated state change to gift #{id}?`,"collectibles.manage":`Attribute pool`,"collectibles.title":`Collectible pool · Gift #{id}`,"collectibles.eyebrow":`Unique gift attributes`,"collectibles.activeRevision":`Published revision {revision}`,"collectibles.published":`Published`,"collectibles.noPool":`No collectible pool published`,"collectibles.noPoolHint":`Publish models, patterns and backdrops to enable upgrades.`,"collectibles.publishNew":`Publish a new immutable revision`,"collectibles.immutableHint":`Dry-run checks every file and rarity total before the revision becomes active.`,"collectibles.upgradeStars":`Upgrade price in Stars`,"collectibles.supply":`Unique supply`,"collectibles.slug":`Public slug prefix`,"collectibles.models":`Models`,"collectibles.patterns":`Patterns`,"collectibles.backdrops":`Backdrops`,"collectibles.model":`Model`,"collectibles.pattern":`Pattern`,"collectibles.backdrop":`Backdrop`,"collectibles.rarity":`Rarity ‰`,"collectibles.rarityHint":`Every section must total exactly 1000‰.`,"collectibles.colorHint":`Colors are stored as Telegram 24-bit RGB values.`,"collectibles.addAttribute":`Add`,"collectibles.remove":`Remove attribute`,"collectibles.fileRequired":`Every model and pattern needs a TGS or Lottie file.`,"collectibles.backdropID":`Backdrop ID`,"collectibles.color.center":`Center`,"collectibles.color.edge":`Edge`,"collectibles.color.pattern":`Pattern`,"collectibles.color.text":`Text`,"collectibles.validationReady":`Attribute pool is valid`,"collectibles.validationHint":`Review the normalized assets, then publish this immutable revision.`,"collectibles.publish":`Publish revision`,"messages.msgIDsInvalid":`Message IDs are invalid`,"auth.device":`Device`,"auth.platform":`Platform`,"auth.ip":`IP`,"auth.lastActive":`Last active`,"auth.revokeCurrent":`Revoke current`,"auth.keepCurrent":`Keep current`,"auth.revokeAll":`Revoke all devices`,"picker.userPlaceholder":`Search user_id / phone / username`,"picker.channelPlaceholder":`Search channel_id / username / title`,"picker.verified":`Verified`,"picker.regular":`Regular`,"action.reasonRequired":`Please enter an operation reason`,"action.flow":`Action Flow`,"action.close":`Close`,"action.stepReason":`Enter reason`,"action.stepDryRun":`Dry-run check`,"action.stepConfirm":`Confirm execution`,"action.reason":`Operation reason`,"action.reasonPlaceholder":`Describe why this operation is being performed`,"action.requestPreview":`Request preview`,"action.result":`Action result`,"action.commandID":`Command ID`,"action.status":`Status`,"action.dryRun":`Dry-run`,"action.runAgain":`Run dry-run again`,"action.runDry":`Run dry-run first`,"action.confirm":`Confirm execution`,"audit.id":`ID`,"audit.commandID":`Command ID`,"audit.action":`Action`,"audit.actor":`Actor`,"audit.status":`Status`,"audit.dryRun":`Dry-run`,"audit.reason":`Reason`,"audit.time":`Time`},zh:{"app.adminConsole":`管理控制台`,"app.localAccess":`本地访问`,"app.title":`telesrv 管理后台`,"common.actions":`操作`,"common.admins":`管理员`,"common.backToList":`返回列表`,"common.channel":`频道`,"common.channelOrGroup":`频道/群`,"common.clear":`清除`,"common.close":`关闭`,"common.count":`数量`,"common.deleted":`已删除`,"common.detail":`详情`,"common.device":`设备`,"common.disabled":`已禁用`,"common.enabled":`已启用`,"common.fromPeer":`From Peer`,"common.group":`群组`,"common.id":`ID`,"common.limit":`条数`,"common.loading":`加载中`,"common.member":`成员`,"common.members":`成员`,"common.messageId":`消息 ID`,"common.name":`姓名`,"common.no":`否`,"common.noResults":`无结果`,"common.none":`无`,"common.normal":`正常`,"common.operations":`操作`,"common.owner":`所属`,"common.platform":`平台`,"common.refresh":`刷新`,"common.search":`查询`,"common.sender":`发送方`,"common.status":`状态`,"common.survived":`存活`,"common.time":`时间`,"common.type":`类型`,"common.updatedAt":`更新时间`,"common.username":`用户名`,"common.valid":`有效`,"common.verified":`已认证`,"common.views":`浏览`,"common.yes":`是`,"route.accounts":`账号管理`,"route.accountsSubtitle":`控制台 / 账号`,"route.channels":`超级群与频道`,"route.channelsSubtitle":`控制台 / 频道`,"route.dashboard":`运维控制台`,"route.dashboardSubtitle":`控制台 / 总览`,"route.messages":`消息审计`,"route.messagesSubtitle":`控制台 / 消息`,"route.gifts":`星星礼物`,"route.giftsSubtitle":`控制台 / 星星礼物`,"layout.navigation":`导航`,"layout.primaryNav":`主导航`,"layout.dashboard":`总览`,"layout.accounts":`账号`,"layout.channels":`超级群/频道`,"layout.messages":`消息`,"layout.gifts":`礼物目录`,"layout.privateMessages":`私聊`,"layout.groupMessages":`群聊`,"layout.runtime":`运行状态`,"layout.adminBackend":`管理后台`,"layout.ready":`就绪`,"layout.pgRead":`PG 读取`,"layout.readOnly":`只读`,"layout.writeOps":`写操作`,"layout.dryRun":`预演`,"layout.actor":`操作者:{actor}`,"layout.logout":`退出`,"language.en":`EN`,"language.zh":`中文`,"language.ru":`RU`,"login.heading":`运维后台`,"login.body":`输入凭据后进入控制台。`,"login.secret":`管理员密码或 token`,"login.submit":`登录`,"login.submitting":`登录中`,"dashboard.eyebrow":`运行总览`,"dashboard.title":`控制台总览`,"dashboard.readPath":`读路径`,"dashboard.readPathValue":`PG 只读`,"dashboard.writePath":`写路径`,"dashboard.executionPolicy":`执行策略`,"dashboard.dryRunFirst":`先预演`,"dashboard.accountsText":`账号状态、会员、认证、会话。`,"dashboard.channelsText":`公开实体、成员计数、认证状态。`,"dashboard.messagesText":`消息盒、update、outbox 状态。`,"dashboard.strip.dryRun":`所有危险操作先预演`,"dashboard.strip.token":`浏览器不持有内部 token`,"dashboard.strip.pagination":`列表使用游标分页`,"dashboard.strip.snapshot":`详情页保留原始状态快照`,"account.pageTitle":`账号`,"account.queryResults":`查询结果`,"account.recentActive":`最近活跃账号`,"account.currentPage":`当前页账号`,"account.onlineDevices":`在线设备记录`,"account.premium":`会员`,"account.frozen":`冻结`,"account.searchPlaceholder":`用户 ID / 手机号 / 用户名`,"account.userID":`用户 ID`,"account.phone":`手机号`,"account.lastActive":`最近活跃`,"account.notVerified":`未认证`,"account.notPremium":`非会员`,"account.premiumUntil":`会员到期`,"account.starsBalance":`Stars 余额`,"account.startingGrantApplied":`初始赠送已发放`,"account.startingGrantPending":`初始赠送未触发`,"account.activeSessions":`授权设备`,"account.accountFlags":`账号标记`,"account.restriction":`限制状态`,"account.restricted":`已限制`,"account.createdAt":`创建时间`,"account.detailTitle":`账号 #{id}`,"account.profile":`账号档案`,"account.loadingDetail":`加载账号详情`,"account.waitingData":`等待数据`,"account.noUsername":`无用户名`,"account.noPhone":`无手机号`,"account.accountFrozen":`账号已冻结`,"account.accountActive":`账号正常`,"account.authorizationsTitle":`授权设备`,"account.authorizationsCount":`共 {count} 个授权`,"account.recentAdminOps":`最近后台操作`,"account.recent30Audit":`最近 30 条审计`,"account.actionDock":`账号操作`,"account.freezeAccount":`冻结账号`,"account.updateFreeze":`更新冻结信息`,"account.unfreezeAccount":`解冻账号`,"account.freezeSince":`冻结开始时间`,"account.freezeUntil":`申诉截止时间`,"account.freezeUntilAria":`账号冻结申诉截止时间`,"account.freezeAppealURL":`申诉链接`,"account.freezeAppealURLAria":`账号冻结申诉链接`,"account.premiumMonths":`会员时长(月)`,"account.premiumMonthsAria":`设置会员时长,单位月`,"account.setPremium":`设置会员`,"account.clearPremium":`取消会员`,"account.starsAmount":`赠送 Stars 数量`,"account.starsAmountAria":`设置要赠送的 Stars 数量`,"account.grantStars":`赠送 Stars`,"account.setVerified":`设置认证`,"account.clearVerified":`取消认证`,"channel.pageTitle":`超级群与频道`,"channel.recentUpdated":`最近更新`,"channel.currentPage":`当前页实体`,"channel.megagroups":`超级群`,"channel.broadcasts":`频道`,"channel.verifiedCount":`已认证`,"channel.searchPlaceholder":`频道 ID / 用户名 / 标题`,"channel.channelID":`频道 ID`,"channel.kind":`类型`,"channel.title":`标题`,"channel.pts":`PTS`,"channel.detailProfile":`频道档案`,"channel.loadingDetail":`加载频道详情`,"channel.creator":`创建者 {id}`,"channel.governance":`治理状态`,"channel.governanceValue":`封禁 {banned} / 踢出 {kicked}`,"channel.flags":`频道标记`,"channel.rawRow":`频道原始行`,"channel.rawRowText":`数据库只读快照`,"channel.actionDock":`频道操作`,"channel.setVerified":`设置认证`,"channel.clearVerified":`取消认证`,"channel.kind.broadcast":`频道`,"channel.kind.forum":`超级群/论坛`,"channel.kind.megagroup":`超级群`,"channel.kind.generic":`频道/群`,"messages.privateTitle":`私聊消息`,"messages.privateEyebrow":`私聊消息盒`,"messages.groupTitle":`群聊消息`,"messages.groupEyebrow":`超级群 / 频道消息`,"messages.selectPrivatePeers":`请先搜索并选择所属用户和对端用户`,"messages.selectChannel":`请先搜索并选择超级群或频道`,"messages.ownerUser":`所属用户`,"messages.peerUser":`对端用户`,"messages.beforeDatePlaceholder":`before_date 游标`,"messages.beforeIDPlaceholder":`before_msg_id 游标`,"messages.limitPlaceholder":`条数 <= 100`,"messages.searchMessages":`查询消息`,"messages.nextPage":`下一页`,"messages.currentPage":`当前页消息`,"messages.deleted":`已删除`,"messages.outgoing":`发出消息`,"messages.incoming":`收到`,"messages.ownerPeer":`所属 / 对端`,"messages.deleteSelected":`删除指定消息`,"messages.idsPlaceholder":`消息 ID,逗号分隔`,"messages.revoke":`同步撤回`,"messages.previewDelete":`预演删除`,"messages.clearHistory":`清空私聊历史`,"messages.maxIDPlaceholder":`max_id 截止消息`,"messages.maxBatchesPlaceholder":`max_batches 批次数`,"messages.justClear":`仅清本侧`,"messages.previewClearHistory":`预演清历史`,"messages.direction":`方向`,"messages.body":`正文`,"messages.privateDetailTitle":`消息 #{id}`,"messages.detailEyebrow":`消息详情`,"messages.backPrivate":`返回私聊消息`,"messages.backGroup":`返回群聊消息`,"messages.ownerPeerTitle":`所属 {owner} · 对端 {peer}`,"messages.senderSubtitle":`发送方 {sender} · {date}`,"messages.boxID":`消息盒 ID`,"messages.privateMessageID":`私聊消息 ID`,"messages.messageSender":`发送方`,"messages.messageBox":`消息盒`,"messages.dialogRow":`会话行`,"messages.privateRow":`私聊消息行`,"messages.channelMessageRow":`消息行`,"messages.channelRow":`频道行`,"messages.userUpdateEvents":`更新事件`,"messages.channelUpdateEvents":`频道更新事件`,"messages.eventJson":`事件 JSON`,"messages.dispatchOutbox":`分发队列`,"messages.messageBoxesSnapshot":`message_boxes 只读快照`,"messages.dialogSnapshot":`dialogs 只读快照`,"messages.privateSnapshot":`private_messages 只读快照`,"messages.channelMessagesSnapshot":`channel_messages 只读快照`,"messages.channelSnapshot":`channels 只读快照`,"messages.userEventsSource":`durable user_update_events`,"messages.channelEventsSource":`durable channel_update_events`,"messages.outboxSource":`在线/离线 dispatch_outbox`,"messages.attempts":`尝试`,"messages.deleteThis":`删除此消息`,"messages.groupDetailTitle":`群聊消息 #{id}`,"messages.channelGroupTitle":`频道/群 {id}`,"messages.mediaCount":`有媒体`,"messages.channelPosts":`频道帖子`,"messages.channelGroup":`频道 / 群`,"messages.pinned":`置顶`,"messages.channelPost":`频道帖子`,"gifts.pageTitle":`星星礼物目录`,"gifts.eyebrow":`目录、不可变版本与动画资源`,"gifts.total":`目录条目`,"gifts.enabled":`已启用`,"gifts.received":`已领取礼物`,"gifts.formats":`支持格式`,"gifts.add":`添加礼物`,"gifts.searchPlaceholder":`搜索礼物 ID、标题或格式`,"gifts.listSummary":`显示 {shown} / {total} 项`,"gifts.idRevision":`ID / 版本`,"gifts.price":`售价 / 兑换`,"gifts.importTitle":`导入星星礼物`,"gifts.importEyebrow":`礼物目录操作`,"gifts.newRevision":`为礼物 #{id} 创建新版本`,"gifts.importHint":`支持 TGS 或纯 Lottie JSON;Lottie 会规范化并压缩成 TGS。`,"gifts.animation":`动画文件`,"gifts.filePrompt":`拖放或选择 TGS / Lottie 文件`,"gifts.fileHint":`支持 TGS、JSON、Lottie,导入前会先进行校验`,"gifts.chooseFile":`选择文件`,"gifts.changeFile":`更换文件`,"gifts.title":`显示标题`,"gifts.titlePlaceholder":`例如:庆典星星`,"gifts.stars":`售价 Stars`,"gifts.convertStars":`可兑换 Stars`,"gifts.sortOrder":`排序值`,"gifts.reason":`审计原因`,"gifts.reasonPlaceholder":`简要说明本次导入礼物的原因`,"gifts.enableAfterImport":`导入后启用`,"gifts.validate":`Dry-run 校验`,"gifts.confirmImport":`确认导入`,"gifts.stepDetails":`文件与信息`,"gifts.stepValidate":`Dry-run 校验`,"gifts.stepImport":`确认导入`,"gifts.fileRequired":`请先选择 TGS 或 Lottie 文件`,"gifts.source":`来源`,"gifts.replace":`创建新版本`,"gifts.disable":`停用`,"gifts.enable":`启用`,"gifts.empty":`尚未导入星星礼物。`,"gifts.emptyHint":`从上方导入第一个动画,开始搭建礼物目录。`,"gifts.validationReady":`校验已通过`,"gifts.validationHint":`确认规范化后的元数据无误,再执行正式导入。`,"gifts.confirmState":`确认执行礼物 #{id} 的状态变更吗?`,"collectibles.manage":`属性池`,"collectibles.title":`Collectibles 属性池 · 礼物 #{id}`,"collectibles.eyebrow":`唯一礼物属性管理`,"collectibles.activeRevision":`已发布版本 {revision}`,"collectibles.published":`已发布`,"collectibles.noPool":`尚未发布 Collectibles 属性池`,"collectibles.noPoolHint":`发布模型、图案与背景后,客户端即可升级为唯一礼物。`,"collectibles.publishNew":`发布新的不可变版本`,"collectibles.immutableHint":`Dry-run 会校验全部文件和稀有度总和,通过后才切换为当前版本。`,"collectibles.upgradeStars":`升级价格 Stars`,"collectibles.supply":`唯一礼物总量`,"collectibles.slug":`公开 Slug 前缀`,"collectibles.models":`模型`,"collectibles.patterns":`图案`,"collectibles.backdrops":`背景`,"collectibles.model":`模型`,"collectibles.pattern":`图案`,"collectibles.backdrop":`背景`,"collectibles.rarity":`稀有度 ‰`,"collectibles.rarityHint":`每一类的稀有度总和必须正好为 1000‰。`,"collectibles.colorHint":`颜色会按 Telegram 24 位 RGB 数值保存。`,"collectibles.addAttribute":`添加`,"collectibles.remove":`删除属性`,"collectibles.fileRequired":`每个模型和图案都必须选择 TGS 或 Lottie 文件。`,"collectibles.backdropID":`背景 ID`,"collectibles.color.center":`中心色`,"collectibles.color.edge":`边缘色`,"collectibles.color.pattern":`图案色`,"collectibles.color.text":`文字色`,"collectibles.validationReady":`属性池校验通过`,"collectibles.validationHint":`确认规范化资源无误后,即可发布这个不可变版本。`,"collectibles.publish":`发布版本`,"messages.msgIDsInvalid":`消息 ID 无效`,"auth.device":`设备`,"auth.platform":`平台`,"auth.ip":`IP`,"auth.lastActive":`最近活跃`,"auth.revokeCurrent":`撤销当前`,"auth.keepCurrent":`保留当前`,"auth.revokeAll":`撤销全部设备`,"picker.userPlaceholder":`搜索 user_id / phone / username`,"picker.channelPlaceholder":`搜索 channel_id / username / title`,"picker.verified":`认证`,"picker.regular":`普通`,"action.reasonRequired":`请填写操作原因`,"action.flow":`操作流程`,"action.close":`关闭`,"action.stepReason":`填写原因`,"action.stepDryRun":`预演检查`,"action.stepConfirm":`确认执行`,"action.reason":`操作原因`,"action.reasonPlaceholder":`说明本次操作原因`,"action.requestPreview":`请求预览`,"action.result":`操作结果`,"action.commandID":`命令 ID`,"action.status":`状态`,"action.dryRun":`预演`,"action.runAgain":`重新预演`,"action.runDry":`先预演`,"action.confirm":`确认执行`,"audit.id":`ID`,"audit.commandID":`命令 ID`,"audit.action":`动作`,"audit.actor":`操作者`,"audit.status":`状态`,"audit.dryRun":`预演`,"audit.reason":`原因`,"audit.time":`时间`},ru:{"app.adminConsole":`Панель администратора`,"app.localAccess":`Локальный доступ`,"app.title":`telesrv admin`,"common.actions":`Действия`,"common.admins":`Администраторы`,"common.backToList":`Назад к списку`,"common.channel":`Канал`,"common.channelOrGroup":`Канал / Группа`,"common.clear":`Очистить`,"common.close":`Закрыть`,"common.count":`Количество`,"common.deleted":`Удалено`,"common.detail":`Детали`,"common.device":`Устройство`,"common.disabled":`Отключено`,"common.enabled":`Включено`,"common.fromPeer":`От пира`,"common.group":`Группа`,"common.id":`ID`,"common.limit":`Лимит`,"common.loading":`Загрузка...`,"common.member":`Участник`,"common.members":`Участники`,"common.messageId":`ID сообщения`,"common.name":`Имя`,"common.no":`Нет`,"common.noResults":`Нет результатов`,"common.none":`Нет`,"common.normal":`Обычный`,"common.operations":`Операции`,"common.owner":`Владелец`,"common.platform":`Платформа`,"common.refresh":`Обновить`,"common.search":`Поиск`,"common.sender":`Отправитель`,"common.status":`Статус`,"common.survived":`Уцелело`,"common.time":`Время`,"common.type":`Тип`,"common.updatedAt":`Обновлено`,"common.username":`Имя пользователя`,"common.valid":`Действителен`,"common.verified":`Подтвержден`,"common.views":`Просмотры`,"common.yes":`Да`,"route.accounts":`Аккаунты`,"route.accountsSubtitle":`Консоль / Аккаунты`,"route.channels":`Супергруппы и каналы`,"route.channelsSubtitle":`Консоль / Каналы`,"route.dashboard":`Панель управления`,"route.dashboardSubtitle":`Консоль / Обзор`,"route.messages":`Аудит сообщений`,"route.messagesSubtitle":`Консоль / Сообщения`,"route.gifts":`Звездные подарки`,"route.giftsSubtitle":`Консоль / Звездные подарки`,"layout.navigation":`Навигация`,"layout.primaryNav":`Основное меню`,"layout.dashboard":`Обзор`,"layout.accounts":`Аккаунты`,"layout.channels":`Супергруппы / Каналы`,"layout.messages":`Сообщения`,"layout.gifts":`Звездные подарки`,"layout.privateMessages":`Личные`,"layout.groupMessages":`Группы`,"layout.runtime":`Среда выполнения`,"layout.adminBackend":`Админ-бэкенд`,"layout.ready":`Готов`,"layout.pgRead":`Чтение из PG`,"layout.readOnly":`Только чтение`,"layout.writeOps":`Операции записи`,"layout.dryRun":`Тестовый запуск`,"layout.actor":`Вход выполнен как: {actor}`,"layout.logout":`Выйти`,"language.en":`EN`,"language.zh":`中文`,"language.ru":`RU`,"login.heading":`Панель администратора`,"login.body":`Введите учетные данные для входа в консоль.`,"login.secret":`Пароль или токен администратора`,"login.submit":`Войти`,"login.submitting":`Вход...`,"dashboard.eyebrow":`Состояние системы`,"dashboard.title":`Обзор консоли`,"dashboard.readPath":`Путь чтения`,"dashboard.readPathValue":`PG только для чтения`,"dashboard.writePath":`Путь записи`,"dashboard.executionPolicy":`Политика выполнения`,"dashboard.dryRunFirst":`Сначала тестовый запуск`,"dashboard.accountsText":`Статус аккаунтов, премиум, верификация, сессии.`,"dashboard.channelsText":`Публичные каналы и группы, количество участников, статус верификации.`,"dashboard.messagesText":`Ящики сообщений, обновления, состояние исходящих.`,"dashboard.strip.dryRun":`Все опасные действия начинаются с тестового запуска`,"dashboard.strip.token":`Браузер никогда не сохраняет внутренние токены`,"dashboard.strip.pagination":`Списки используют курсорную пагинацию`,"dashboard.strip.snapshot":`Детальные страницы сохраняют моментальные снимки исходного состояния`,"account.pageTitle":`Аккаунты`,"account.queryResults":`Результаты поиска`,"account.recentActive":`Недавно активные аккаунты`,"account.currentPage":`Аккаунты на странице`,"account.onlineDevices":`Активные сессии устройств`,"account.premium":`Premium`,"account.frozen":`Заморожен`,"account.searchPlaceholder":`ID пользователя / телефон / имя пользователя`,"account.userID":`ID пользователя`,"account.phone":`Телефон`,"account.lastActive":`Последняя активность`,"account.notVerified":`Не подтвержден`,"account.notPremium":`Без Premium`,"account.premiumUntil":`Premium истекает`,"account.starsBalance":`Баланс Звезд`,"account.startingGrantApplied":`стартовый бонус начислен`,"account.startingGrantPending":`ожидает стартового бонуса`,"account.activeSessions":`Авторизованные устройства`,"account.accountFlags":`Флаги аккаунта`,"account.restriction":`Ограничение`,"account.restricted":`Ограничен`,"account.createdAt":`Создан`,"account.detailTitle":`Аккаунт #{id}`,"account.profile":`Профиль аккаунта`,"account.loadingDetail":`Загрузка данных аккаунта`,"account.waitingData":`Ожидание данных`,"account.noUsername":`Нет имени пользователя`,"account.noPhone":`Нет телефона`,"account.accountFrozen":`Аккаунт заморожен`,"account.accountActive":`Аккаунт активен`,"account.authorizationsTitle":`Авторизованные устройства`,"account.authorizationsCount":`Авторизаций: {count}`,"account.recentAdminOps":`Последние действия администратора`,"account.recent30Audit":`Последние 30 записей аудита`,"account.actionDock":`Действия с аккаунтом`,"account.freezeAccount":`Заморозить аккаунт`,"account.updateFreeze":`Обновить параметры заморозки`,"account.unfreezeAccount":`Разморозить аккаунт`,"account.freezeSince":`Заморожен с`,"account.freezeUntil":`Срок подачи апелляции`,"account.freezeUntilAria":`Срок подачи апелляции на заморозку`,"account.freezeAppealURL":`URL для апелляции`,"account.freezeAppealURLAria":`URL для апелляции на заморозку`,"account.premiumMonths":`Срок действия Premium (в месяцах)`,"account.premiumMonthsAria":`Указать срок действия Premium в месяцах`,"account.setPremium":`Выдать Premium`,"account.clearPremium":`Снять Premium`,"account.starsAmount":`Количество звёзд`,"account.starsAmountAria":`Указать количество начисляемых звёзд`,"account.grantStars":`Начислить звёзды`,"account.setVerified":`Подтвердить аккаунт`,"account.clearVerified":`Снять подтверждение`,"channel.pageTitle":`Супергруппы и каналы`,"channel.recentUpdated":`Недавно обновленные`,"channel.currentPage":`Объекты на странице`,"channel.megagroups":`Супергруппы`,"channel.broadcasts":`Каналы`,"channel.verifiedCount":`Подтверждено`,"channel.searchPlaceholder":`ID канала / имя пользователя / название`,"channel.channelID":`ID канала`,"channel.kind":`Тип`,"channel.title":`Название`,"channel.pts":`PTS`,"channel.detailProfile":`Профиль канала`,"channel.loadingDetail":`Загрузка данных канала`,"channel.creator":`Создатель: {id}`,"channel.governance":`Модерация`,"channel.governanceValue":`Заблокировано {banned} / Исключено {kicked}`,"channel.flags":`Флаги канала`,"channel.rawRow":`Исходная строка БД`,"channel.rawRowText":`Снимок базы данных только для чтения`,"channel.actionDock":`Действия с каналом`,"channel.setVerified":`Подтвердить канал`,"channel.clearVerified":`Снять подтверждение`,"channel.kind.broadcast":`Канал`,"channel.kind.forum":`Супергруппа / Форум`,"channel.kind.megagroup":`Супергруппа`,"channel.kind.generic":`Канал / Группа`,"messages.privateTitle":`Личные сообщения`,"messages.privateEyebrow":`Личные ящики сообщений`,"messages.groupTitle":`Групповые сообщения`,"messages.groupEyebrow":`Сообщения супергрупп и каналов`,"messages.selectPrivatePeers":`Сначала найдите и выберите владельца и собеседника`,"messages.selectChannel":`Сначала найдите и выберите супергруппу или канал`,"messages.ownerUser":`Пользователь-владелец`,"messages.peerUser":`Собеседник`,"messages.beforeDatePlaceholder":`курсор before_date`,"messages.beforeIDPlaceholder":`курсор before_msg_id`,"messages.limitPlaceholder":`лимит <= 100`,"messages.searchMessages":`Поиск сообщений`,"messages.nextPage":`Следующая страница`,"messages.currentPage":`Сообщения на странице`,"messages.deleted":`Удалено`,"messages.outgoing":`Исходящее`,"messages.incoming":`Входящее`,"messages.ownerPeer":`Владелец / Собеседник`,"messages.deleteSelected":`Указать и удалить выбранные сообщения`,"messages.idsPlaceholder":`ID сообщений через запятую`,"messages.revoke":`Удалить для обеих сторон`,"messages.previewDelete":`Тестовое удаление`,"messages.clearHistory":`Очистить историю личной переписки`,"messages.maxIDPlaceholder":`граница max_id`,"messages.maxBatchesPlaceholder":`max_batches`,"messages.justClear":`Очистить только у себя`,"messages.previewClearHistory":`Тестовая очистка истории`,"messages.direction":`Направление`,"messages.body":`Текст сообщения`,"messages.privateDetailTitle":`Сообщение #{id}`,"messages.detailEyebrow":`Детали сообщения`,"messages.backPrivate":`Назад к личным сообщениям`,"messages.backGroup":`Назад к групповым сообщениям`,"messages.ownerPeerTitle":`Владелец {owner} · Собеседник {peer}`,"messages.senderSubtitle":`Отправитель {sender} · {date}`,"messages.boxID":`ID ящика сообщений`,"messages.privateMessageID":`ID личного сообщения`,"messages.messageSender":`Отправитель сообщения`,"messages.messageBox":`Ящик сообщений`,"messages.dialogRow":`Строка диалога`,"messages.privateRow":`Строка личного сообщения`,"messages.channelMessageRow":`Строка сообщения канала`,"messages.channelRow":`Строка канала`,"messages.userUpdateEvents":`События обновления пользователей`,"messages.channelUpdateEvents":`События обновления каналов`,"messages.eventJson":`JSON события`,"messages.dispatchOutbox":`Очередь отправки (Outbox)`,"messages.messageBoxesSnapshot":`Снимок message_boxes только для чтения`,"messages.dialogSnapshot":`Снимок dialogs только для чтения`,"messages.privateSnapshot":`Снимок private_messages только для чтения`,"messages.channelMessagesSnapshot":`Снимок channel_messages только для чтения`,"messages.channelSnapshot":`Снимок channels только для чтения`,"messages.userEventsSource":`постоянные user_update_events`,"messages.channelEventsSource":`постоянные channel_update_events`,"messages.outboxSource":`онлайн/офлайн dispatch_outbox`,"messages.attempts":`Попытки`,"messages.deleteThis":`Удалить это сообщение`,"messages.groupDetailTitle":`Групповое сообщение #{id}`,"messages.channelGroupTitle":`Канал / Группа {id}`,"messages.mediaCount":`С медиафайлами`,"messages.channelPosts":`Посты канала`,"messages.channelGroup":`Канал / Группа`,"messages.pinned":`Закреплено`,"messages.channelPost":`Пост в канале`,"gifts.pageTitle":`Каталог звездных подарков`,"gifts.eyebrow":`Каталог, неизменяемые версии и файлы анимаций`,"gifts.total":`Подарков в каталоге`,"gifts.enabled":`Включено`,"gifts.received":`Полученные подарки`,"gifts.formats":`Поддерживаемые форматы`,"gifts.add":`Добавить подарок`,"gifts.searchPlaceholder":`Поиск по ID подарка, названию или формату`,"gifts.listSummary":`Показано {shown} из {total}`,"gifts.idRevision":`ID / Версия`,"gifts.price":`Цена / Конвертация`,"gifts.importTitle":`Импорт звездного подарка`,"gifts.importEyebrow":`Управление каталогом подарков`,"gifts.newRevision":`Создать версию для подарка #{id}`,"gifts.importHint":`Загрузите файл TGS или обычный Lottie JSON. Lottie нормализуется и сжимается в формат TGS.`,"gifts.animation":`Файл анимации`,"gifts.filePrompt":`Перетащите или выберите файл TGS / Lottie`,"gifts.fileHint":`TGS, JSON или Lottie · файл проверяется перед импортом`,"gifts.chooseFile":`Выбрать файл`,"gifts.changeFile":`Изменить файл`,"gifts.title":`Отображаемое название`,"gifts.titlePlaceholder":`например, Праздничная звезда`,"gifts.stars":`Цена в Звездах`,"gifts.convertStars":`Звезд при конвертации`,"gifts.sortOrder":`Порядок сортировки`,"gifts.reason":`Причина для аудита`,"gifts.reasonPlaceholder":`Кратко опишите причину импорта этого подарка`,"gifts.enableAfterImport":`Включить после импорта`,"gifts.validate":`Тестовая проверка`,"gifts.confirmImport":`Подтвердить импорт`,"gifts.stepDetails":`Файл и описание`,"gifts.stepValidate":`Тестовая проверка`,"gifts.stepImport":`Подтверждение импорта`,"gifts.fileRequired":`Сначала выберите файл TGS или Lottie`,"gifts.source":`Источник`,"gifts.replace":`Новая версия`,"gifts.disable":`Отключить`,"gifts.enable":`Включить`,"gifts.empty":`Звездные подарки еще не импортированы.`,"gifts.emptyHint":`Импортируйте первую анимацию, чтобы начать наполнение каталога.`,"gifts.validationReady":`Проверка пройдена`,"gifts.validationHint":`Проверьте нормализованные метаданные и подтвердите импорт.`,"gifts.confirmState":`Применить проверенные изменения состояния к подарку #{id}?`,"collectibles.manage":`Пул атрибутов`,"collectibles.title":`Пул коллекционных предметов · Подарок #{id}`,"collectibles.eyebrow":`Уникальные атрибуты подарка`,"collectibles.activeRevision":`Опубликованная версия {revision}`,"collectibles.published":`Опубликовано`,"collectibles.noPool":`Нет опубликованного пула коллекционных предметов`,"collectibles.noPoolHint":`Опубликуйте модели, узоры и фоны для активации улучшений.`,"collectibles.publishNew":`Опубликовать новую неизменяемую версию`,"collectibles.immutableHint":`Тестовый запуск проверяет каждый файл и итоговые показатели редкости перед тем, как версия станет активной.`,"collectibles.upgradeStars":`Цена улучшения в Звездах`,"collectibles.supply":`Уникальный тираж`,"collectibles.slug":`Публичный префикс ссылки (slug)`,"collectibles.models":`Модели`,"collectibles.patterns":`Узоры`,"collectibles.backdrops":`Фоны`,"collectibles.model":`Модель`,"collectibles.pattern":`Узор`,"collectibles.backdrop":`Фон`,"collectibles.rarity":`Редкость ‰`,"collectibles.rarityHint":`Сумма по каждому разделу должна составлять ровно 1000‰.`,"collectibles.colorHint":`Цвета сохраняются как 24-битные RGB-значения Telegram.`,"collectibles.addAttribute":`Добавить`,"collectibles.remove":`Удалить атрибут`,"collectibles.fileRequired":`Для каждой модели и узора требуется файл TGS или Lottie.`,"collectibles.backdropID":`ID фона`,"collectibles.color.center":`Центр`,"collectibles.color.edge":`Край`,"collectibles.color.pattern":`Узор`,"collectibles.color.text":`Текст`,"collectibles.validationReady":`Пул атрибутов корректен`,"collectibles.validationHint":`Проверьте нормализованные ресурсы и опубликуйте эту неизменяемую версию.`,"collectibles.publish":`Опубликовать версию`,"messages.msgIDsInvalid":`Некорректные ID сообщений`,"auth.device":`Устройство`,"auth.platform":`Платформа`,"auth.ip":`IP-адрес`,"auth.lastActive":`Последняя активность`,"auth.revokeCurrent":`Отозвать текущую`,"auth.keepCurrent":`Оставить текущую`,"auth.revokeAll":`Разлогинить все устройства`,"picker.userPlaceholder":`Поиск по user_id / телефону / имени пользователя`,"picker.channelPlaceholder":`Поиск по channel_id / имени пользователя / названию`,"picker.verified":`Подтвержденные`,"picker.regular":`Обычные`,"action.reasonRequired":`Пожалуйста, укажите причину операции`,"action.flow":`Процесс выполнения`,"action.close":`Закрыть`,"action.stepReason":`Укажите причину`,"action.stepDryRun":`Тестовый запуск`,"action.stepConfirm":`Подтверждение выполнения`,"action.reason":`Причина операции`,"action.reasonPlaceholder":`Опишите, почему выполняется эта операция`,"action.requestPreview":`Запросить предпросмотр`,"action.result":`Результат действия`,"action.commandID":`ID команды`,"action.status":`Статус`,"action.dryRun":`Тестовый запуск`,"action.runAgain":`Запустить тестовый запуск снова`,"action.runDry":`Сначала выполните тестовый запуск`,"action.confirm":`Подтвердить выполнение`,"audit.id":`ID`,"audit.commandID":`ID команды`,"audit.action":`Действие`,"audit.actor":`Исполнитель`,"audit.status":`Статус`,"audit.dryRun":`Тестовый запуск`,"audit.reason":`Причина`,"audit.time":`Время`}},Se=(0,g.createContext)(null);function Ce({children:e}){let[t,n]=(0,g.useState)(()=>Ee());(0,g.useEffect)(()=>{try{localStorage.setItem(H,t)}catch{}let e=t===`zh`?`zh-CN`:t===`ru`?`ru`:`en`;document.documentElement.lang=e,document.documentElement.dir=`ltr`,document.documentElement.setAttribute(`translate`,`no`),document.body.classList.add(`notranslate`),document.title=Te(t,`app.title`)},[t]);let r=(0,g.useMemo)(()=>({lang:t,setLang:n,t:(e,n)=>Te(t,e,n)}),[t]);return(0,V.jsx)(Se.Provider,{value:r,children:e})}function U(){let e=(0,g.useContext)(Se);if(!e)throw Error(`useI18n must be used inside I18nProvider`);return e}function we(){let{lang:e,setLang:t,t:n}=U();return(0,V.jsx)(`div`,{className:`language-switch`,role:`group`,"aria-label":`Language`,children:[`en`,`zh`,`ru`].map(r=>(0,V.jsx)(`button`,{className:e===r?`active`:``,type:`button`,"aria-pressed":e===r,onClick:()=>t(r),children:n(`language.${r}`)},r))})}function Te(e,t,n){let r=xe[e][t]??xe.en[t]??t;return n?r.replace(/\{(\w+)\}/g,(e,t)=>String(n[t]??``)):r}function Ee(){try{let e=De(new URLSearchParams(window.location.search).get(`lang`));if(e)return e}catch{}try{let e=De(localStorage.getItem(H));if(e)return e}catch{}let e=navigator.languages?.length?navigator.languages:[navigator.language];for(let t of e){let e=De(t);if(e)return e}return`en`}function De(e){if(!e)return null;let t=e.trim().toLowerCase().replace(`_`,`-`);return t===`zh`||t.startsWith(`zh-`)?`zh`:t===`en`||t.startsWith(`en-`)?`en`:t===`ru`||t.startsWith(`ru-`)?`ru`:null}function Oe(){return{href:`${window.location.pathname}${window.location.search}`,path:window.location.pathname,search:new URLSearchParams(window.location.search)}}function ke(e,t){return e.startsWith(`/accounts`)?t(`route.accounts`):e.startsWith(`/channels`)?t(`route.channels`):e.startsWith(`/messages`)?t(`route.messages`):e.startsWith(`/gifts`)?t(`route.gifts`):t(`route.dashboard`)}function Ae(e,t){return e.startsWith(`/accounts`)?t(`route.accountsSubtitle`):e.startsWith(`/channels`)?t(`route.channelsSubtitle`):e.startsWith(`/messages`)?t(`route.messagesSubtitle`):e.startsWith(`/gifts`)?t(`route.giftsSubtitle`):t(`route.dashboardSubtitle`)}function je({href:e,navigate:t,className:n,children:r}){return(0,V.jsx)(`a`,{className:n,href:e,onClick:n=>{n.preventDefault(),t(e)},children:r})}function Me(){let{t:e}=U();return(0,V.jsxs)(`div`,{className:`boot-screen`,children:[(0,V.jsxs)(`div`,{className:`brand compact brand-elevated`,children:[(0,V.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,V.jsxs)(`span`,{children:[(0,V.jsx)(`strong`,{children:`telesrv`}),(0,V.jsx)(`small`,{children:e(`app.adminConsole`)})]})]}),(0,V.jsx)(`div`,{className:`loader-bar`})]})}function Ne({actor:e,route:t,navigate:n,onLogout:r,children:i}){let{t:a}=U(),o=t.path.startsWith(`/messages`),[s,c]=(0,g.useState)(o);(0,g.useEffect)(()=>{o&&c(!0)},[o]);async function l(){await x.logout().catch(()=>void 0),r()}return(0,V.jsxs)(`div`,{className:`shell`,children:[(0,V.jsxs)(`aside`,{className:`sidebar`,children:[(0,V.jsxs)(je,{className:`brand`,href:`/`,navigate:n,children:[(0,V.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,V.jsxs)(`span`,{children:[(0,V.jsx)(`strong`,{children:`telesrv`}),(0,V.jsx)(`small`,{children:a(`app.adminConsole`)})]})]}),(0,V.jsx)(`div`,{className:`sidebar-label`,children:a(`layout.navigation`)}),(0,V.jsxs)(`nav`,{className:`nav-list`,"aria-label":a(`layout.primaryNav`),children:[(0,V.jsx)(Pe,{icon:(0,V.jsx)(oe,{size:16}),href:`/`,route:t,navigate:n,children:a(`layout.dashboard`)}),(0,V.jsx)(Pe,{icon:(0,V.jsx)(ve,{size:16}),href:`/accounts`,route:t,navigate:n,children:a(`layout.accounts`)}),(0,V.jsx)(Pe,{icon:(0,V.jsx)(pe,{size:16}),href:`/channels`,route:t,navigate:n,children:a(`layout.channels`)}),(0,V.jsx)(Pe,{icon:(0,V.jsx)(re,{size:16}),href:`/gifts`,route:t,navigate:n,children:a(`layout.gifts`)}),(0,V.jsxs)(`div`,{className:`nav-section ${o?`active`:``} ${s?`open`:``}`,children:[(0,V.jsxs)(`button`,{className:`nav-section-toggle`,type:`button`,"aria-expanded":s,onClick:()=>c(e=>!e),children:[(0,V.jsx)(ce,{size:16}),(0,V.jsx)(`span`,{children:a(`layout.messages`)}),(0,V.jsx)(F,{className:`nav-section-chevron`,size:15})]}),s&&(0,V.jsxs)(`div`,{className:`nav-children`,children:[(0,V.jsx)(Pe,{href:`/messages/private`,route:t,navigate:n,activeWhen:e=>e===`/messages`||e===`/messages/detail`||e.startsWith(`/messages/private`),children:a(`layout.privateMessages`)}),(0,V.jsx)(Pe,{href:`/messages/groups`,route:t,navigate:n,activeWhen:e=>e.startsWith(`/messages/groups`),children:a(`layout.groupMessages`)})]})]})]}),(0,V.jsxs)(`div`,{className:`sidebar-status`,children:[(0,V.jsx)(`div`,{className:`sidebar-label`,children:a(`layout.runtime`)}),(0,V.jsxs)(`div`,{className:`runtime-row`,children:[(0,V.jsx)(fe,{size:14}),(0,V.jsx)(`span`,{children:a(`layout.adminBackend`)}),(0,V.jsx)(`strong`,{children:a(`layout.ready`)})]}),(0,V.jsxs)(`div`,{className:`runtime-row`,children:[(0,V.jsx)(ee,{size:14}),(0,V.jsx)(`span`,{children:a(`layout.pgRead`)}),(0,V.jsx)(`strong`,{children:a(`layout.readOnly`)})]}),(0,V.jsxs)(`div`,{className:`runtime-row`,children:[(0,V.jsx)(me,{size:14}),(0,V.jsx)(`span`,{children:a(`layout.writeOps`)}),(0,V.jsx)(`strong`,{children:a(`layout.dryRun`)})]})]})]}),(0,V.jsxs)(`div`,{className:`workspace`,children:[(0,V.jsxs)(`header`,{className:`topbar`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`eyebrow`,children:Ae(t.path,a)}),(0,V.jsx)(`h1`,{children:ke(t.path,a)})]}),(0,V.jsxs)(`div`,{className:`topbar-actions`,children:[(0,V.jsx)(we,{}),(0,V.jsx)(`span`,{className:`actor-pill`,children:a(`layout.actor`,{actor:e})}),(0,V.jsxs)(`button`,{className:`btn ghost icon-text`,type:`button`,onClick:l,title:a(`layout.logout`),children:[(0,V.jsx)(se,{size:16}),` `,a(`layout.logout`)]})]})]}),(0,V.jsx)(`main`,{className:`content`,children:i})]})]})}function Pe({href:e,route:t,navigate:n,icon:r,children:i,activeWhen:a}){return(0,V.jsxs)(je,{className:`nav-item ${(a?a(t.path):e===`/`?t.path===`/`:t.path.startsWith(e))?`active`:``}`,href:e,navigate:n,children:[r??(0,V.jsx)(`span`,{"aria-hidden":`true`,className:`nav-dot`}),(0,V.jsx)(`span`,{children:i})]})}function Fe(e){let t=e.trim();return!t||t.startsWith(`+`)?t:/^\d+$/.test(t)?`+${t}`:t}function Ie(e){let t=e.trim();return t?t.startsWith(`@`)?t:`@${t}`:``}function Le(e){return`${e.FirstName||``} ${e.LastName||``}`.trim()||`-`}function Re(e,t){let n=t??(e=>({"channel.kind.broadcast":`Channel`,"channel.kind.forum":`Supergroup / Forum`,"channel.kind.megagroup":`Supergroup`,"channel.kind.generic":`Channel / Group`})[e]??e);return e.Broadcast&&!e.Megagroup?n(`channel.kind.broadcast`):e.Megagroup&&e.Forum?n(`channel.kind.forum`):e.Megagroup?n(`channel.kind.megagroup`):n(`channel.kind.generic`)}function ze(e){if(!e||e.startsWith(`0001-`))return``;let t=new Date(e);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function Be(e){if(!e||e<=0)return``;let t=new Date(e*1e3);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function W(e){if(!e.trim())return 0;let t=Number.parseInt(e,10);return Number.isFinite(t)?t:0}function Ve(e,t=`msg ids invalid`){let n=e.split(/[\s,]+/).map(e=>e.trim()).filter(Boolean).map(e=>Number.parseInt(e,10));if(n.length===0||n.some(e=>!Number.isFinite(e)||e<=0))throw Error(t);return n}function He({title:e,eyebrow:t,children:n,actions:r}){return(0,V.jsxs)(`div`,{className:`page-frame`,children:[(0,V.jsxs)(`div`,{className:`page-title-row`,children:[(0,V.jsxs)(`div`,{children:[t&&(0,V.jsx)(`div`,{className:`eyebrow`,children:t}),(0,V.jsx)(`h2`,{children:e})]}),r&&(0,V.jsx)(`div`,{className:`page-actions`,children:r})]}),n]})}function Ue({children:e}){return(0,V.jsx)(`div`,{className:`query-panel`,children:e})}function We({main:e,side:t}){return(0,V.jsxs)(`div`,{className:`split-layout`,children:[(0,V.jsx)(`div`,{className:`split-main`,children:e}),(0,V.jsx)(`aside`,{className:`split-side`,children:t})]})}function Ge({title:e,text:t,action:n}){return(0,V.jsxs)(`div`,{className:`section-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`h2`,{children:e}),t&&(0,V.jsx)(`p`,{children:t})]}),n&&(0,V.jsx)(`div`,{className:`section-action`,children:n})]})}function Ke({children:e}){return(0,V.jsxs)(`div`,{className:`alert`,children:[(0,V.jsx)(O,{size:16}),` `,(0,V.jsx)(`span`,{children:e})]})}function G({children:e,tone:t=`neutral`}){return(0,V.jsx)(`span`,{className:`badge ${t}`,children:e})}function K({label:e,value:t,tone:n}){return(0,V.jsxs)(`div`,{className:`status-item ${n}`,children:[(0,V.jsx)(`span`,{children:e}),(0,V.jsx)(`strong`,{children:t})]})}function q({label:e,value:t,tone:n=`neutral`,mono:r=!1}){return(0,V.jsxs)(`div`,{className:`metric ${n}`,children:[(0,V.jsx)(`span`,{children:e}),(0,V.jsx)(`strong`,{className:r?`mono`:``,children:t})]})}function J({label:e,value:t,mono:n=!1}){return(0,V.jsxs)(`div`,{className:`summary-item`,children:[(0,V.jsx)(`span`,{children:e}),(0,V.jsx)(`strong`,{className:n?`mono`:``,children:t})]})}function qe({rows:e}){let{t}=U();return(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:t(`audit.id`)}),(0,V.jsx)(`th`,{children:t(`audit.commandID`)}),(0,V.jsx)(`th`,{children:t(`audit.action`)}),(0,V.jsx)(`th`,{children:t(`audit.actor`)}),(0,V.jsx)(`th`,{children:t(`audit.status`)}),(0,V.jsx)(`th`,{children:t(`audit.dryRun`)}),(0,V.jsx)(`th`,{children:t(`audit.reason`)}),(0,V.jsx)(`th`,{children:t(`audit.time`)})]})}),(0,V.jsxs)(`tbody`,{children:[e.map(e=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{children:e.ID}),(0,V.jsx)(`td`,{className:`mono`,children:e.CommandID}),(0,V.jsx)(`td`,{children:e.Action}),(0,V.jsx)(`td`,{children:e.Actor}),(0,V.jsx)(`td`,{children:e.Status}),(0,V.jsx)(`td`,{children:e.DryRun?t(`common.yes`):t(`common.no`)}),(0,V.jsx)(`td`,{className:`truncate`,children:e.Reason}),(0,V.jsx)(`td`,{children:ze(e.CreatedAt)})]},e.ID)),e.length===0&&(0,V.jsx)(Je,{colSpan:8})]})]})})}function Je({colSpan:e}){let{t}=U();return(0,V.jsx)(`tr`,{children:(0,V.jsx)(`td`,{colSpan:e,className:`empty-cell`,children:t(`common.noResults`)})})}function Ye({label:e}){return(0,V.jsx)(`section`,{className:`surface`,children:(0,V.jsx)(`div`,{className:`loading-line`,children:e})})}function Xe({value:e}){return(0,V.jsx)(`pre`,{className:`json-block`,children:e||`{}`})}function Ze({onLogin:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(!1);async function c(t){t.preventDefault(),s(!0),a(``);try{e((await x.login(n)).actor)}catch(e){a(b(e))}finally{s(!1)}}return(0,V.jsx)(`main`,{className:`login-page`,children:(0,V.jsxs)(`section`,{className:`login-panel`,children:[(0,V.jsxs)(`div`,{className:`login-head`,children:[(0,V.jsxs)(`div`,{className:`brand brand-elevated`,children:[(0,V.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,V.jsxs)(`span`,{children:[(0,V.jsx)(`strong`,{children:`telesrv`}),(0,V.jsx)(`small`,{children:t(`app.adminConsole`)})]})]}),(0,V.jsxs)(`div`,{className:`login-head-actions`,children:[(0,V.jsx)(we,{}),(0,V.jsx)(`span`,{className:`login-chip`,children:t(`app.localAccess`)})]})]}),(0,V.jsxs)(`div`,{className:`login-copy`,children:[(0,V.jsx)(`h1`,{children:t(`login.heading`)}),(0,V.jsx)(`p`,{children:t(`login.body`)})]}),i&&(0,V.jsx)(Ke,{children:i}),(0,V.jsxs)(`form`,{className:`form-stack`,onSubmit:c,children:[(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:t(`login.secret`)}),(0,V.jsx)(`input`,{autoFocus:!0,type:`password`,value:n,autoComplete:`current-password`,onChange:e=>r(e.target.value)})]}),(0,V.jsx)(`button`,{className:`btn primary full`,type:`submit`,disabled:o,children:t(o?`login.submitting`:`login.submit`)})]})]})})}var Qe=m();function $e({label:e,path:t,payload:n,icon:r,compact:i=!1,tone:a=`danger`,onDone:o}){let{t:s}=U(),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``),[f,p]=(0,g.useState)(null),[m,h]=(0,g.useState)(``),[_,v]=(0,g.useState)(!1);function y(){d(``),p(null),h(``)}async function S(e){if(!u.trim()){h(s(`action.reasonRequired`));return}v(!0),h(``);try{let r={...n(),reason:u,confirm:e};p(await x.action(t,r)),e&&o?.()}catch(e){h(b(e))}finally{v(!1)}}let C=f?.dry_run&&!f.error,w=`btn ${a===`danger`?`danger`:a===`warn`?`warn`:``} ${i?`compact-btn`:``}`,T=(0,g.useMemo)(()=>{try{return n()}catch(e){return{payload_error:b(e)}}},[c,n]);return(0,V.jsxs)(V.Fragment,{children:[(0,V.jsxs)(`button`,{className:w,type:`button`,onClick:()=>{y(),l(!0)},children:[r,e]}),c&&(0,Qe.createPortal)((0,V.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,V.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":e,children:[(0,V.jsxs)(`div`,{className:`modal-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`eyebrow`,children:s(`action.flow`)}),(0,V.jsx)(`h2`,{children:e})]}),(0,V.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>l(!1),"aria-label":s(`action.close`),children:(0,V.jsx)(ye,{size:15})})]}),(0,V.jsxs)(`div`,{className:`command-body`,children:[(0,V.jsxs)(`div`,{className:`command-steps`,children:[(0,V.jsxs)(`div`,{className:`command-step ${u.trim()?`done`:`active`}`,children:[(0,V.jsx)(`span`,{children:`1`}),(0,V.jsx)(`strong`,{children:s(`action.stepReason`)})]}),(0,V.jsxs)(`div`,{className:`command-step ${f?.dry_run?`done`:u.trim()?`active`:``}`,children:[(0,V.jsx)(`span`,{children:`2`}),(0,V.jsx)(`strong`,{children:s(`action.stepDryRun`)})]}),(0,V.jsxs)(`div`,{className:`command-step ${f&&!f.dry_run&&!f.error?`done`:C?`active`:``}`,children:[(0,V.jsx)(`span`,{children:`3`}),(0,V.jsx)(`strong`,{children:s(`action.stepConfirm`)})]})]}),(0,V.jsxs)(`label`,{className:`form-field`,children:[(0,V.jsx)(`span`,{children:s(`action.reason`)}),(0,V.jsx)(`textarea`,{value:u,onChange:e=>d(e.target.value),rows:3,placeholder:s(`action.reasonPlaceholder`)})]}),(0,V.jsxs)(`div`,{className:`command-preview`,children:[(0,V.jsxs)(`div`,{className:`preview-head`,children:[(0,V.jsx)(te,{size:14}),` `,s(`action.requestPreview`)]}),(0,V.jsx)(Xe,{value:JSON.stringify(T,null,2)})]}),m&&(0,V.jsx)(Ke,{children:m}),f&&(0,V.jsxs)(`div`,{className:`result-box`,children:[(0,V.jsxs)(`div`,{className:`result-title`,children:[f.error?(0,V.jsx)(O,{size:16}):(0,V.jsx)(k,{size:16}),(0,V.jsx)(`strong`,{children:f.message||f.error||s(`action.result`)})]}),(0,V.jsxs)(`div`,{className:`result-line`,children:[(0,V.jsx)(`span`,{children:s(`action.commandID`)}),(0,V.jsx)(`strong`,{children:f.command_id})]}),(0,V.jsxs)(`div`,{className:`result-line`,children:[(0,V.jsx)(`span`,{children:s(`action.status`)}),(0,V.jsx)(`strong`,{children:f.status})]}),(0,V.jsxs)(`div`,{className:`result-line`,children:[(0,V.jsx)(`span`,{children:s(`action.dryRun`)}),(0,V.jsx)(`strong`,{children:f.dry_run?s(`common.yes`):s(`common.no`)})]}),(0,V.jsx)(`div`,{className:`result-message`,children:f.message||f.error}),f.details&&(0,V.jsx)(Xe,{value:JSON.stringify(f.details,null,2)})]})]}),(0,V.jsxs)(`div`,{className:`modal-actions`,children:[(0,V.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>l(!1),children:s(`common.close`)}),(0,V.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>S(!1),disabled:_,children:[_?(0,V.jsx)(A,{size:15,className:`spin`}):(0,V.jsx)(ue,{size:15}),s(f?`action.runAgain`:`action.runDry`)]}),(0,V.jsxs)(`button`,{className:`btn danger icon-text`,type:`button`,onClick:()=>S(!0),disabled:_||!C,children:[(0,V.jsx)(k,{size:15}),s(`action.confirm`)]})]})]})}),document.body)]})}function et({rows:e,userID:t,onDone:n}){let{t:r}=U(),[i,a]=(0,g.useState)(()=>new Set);(0,g.useEffect)(()=>{a(new Set)},[t]);let o=(0,g.useMemo)(()=>e.filter(e=>!i.has(e.Hash)),[e,i]);function s(e){a(t=>e(t)),n()}return(0,V.jsxs)(`div`,{className:`authorization-block`,children:[(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table authorization-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:r(`auth.device`)}),(0,V.jsx)(`th`,{children:r(`auth.platform`)}),(0,V.jsx)(`th`,{children:r(`auth.ip`)}),(0,V.jsx)(`th`,{children:r(`auth.lastActive`)}),(0,V.jsx)(`th`,{className:`device-actions-head`,children:r(`common.actions`)})]})}),(0,V.jsxs)(`tbody`,{children:[o.map(n=>(0,V.jsxs)(`tr`,{children:[(0,V.jsxs)(`td`,{className:`device-text`,children:[n.DeviceModel,` `,n.SystemVersion]}),(0,V.jsxs)(`td`,{className:`device-text`,children:[n.Platform,` `,n.AppVersion]}),(0,V.jsx)(`td`,{children:n.IP}),(0,V.jsx)(`td`,{children:ze(n.ActiveAt)}),(0,V.jsx)(`td`,{className:`device-actions-cell`,children:(0,V.jsxs)(`div`,{className:`device-actions`,children:[(0,V.jsx)($e,{label:r(`auth.revokeCurrent`),icon:(0,V.jsx)(se,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,hash:n.Hash}),onDone:()=>s(e=>new Set([...e,n.Hash]))}),(0,V.jsx)($e,{label:r(`auth.keepCurrent`),icon:(0,V.jsx)(pe,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,keep_hash:n.Hash}),onDone:()=>s(()=>new Set(e.filter(e=>e.Hash!==n.Hash).map(e=>e.Hash)))})]})})]},n.Hash)),o.length===0&&(0,V.jsx)(Je,{colSpan:5})]})]})}),(0,V.jsx)(`div`,{className:`danger-zone`,children:(0,V.jsx)($e,{label:r(`auth.revokeAll`),icon:(0,V.jsx)(N,{size:15}),path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,revoke_all:!0}),onDone:()=>s(()=>new Set(e.map(e=>e.Hash)))})})]})}function tt({id:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(`1`),[d,f]=(0,g.useState)(`1000`),[p,m]=(0,g.useState)(()=>nt(new Date(Date.now()+7*864e5))),[h,_]=(0,g.useState)(``);async function v(){c(!0),o(``);try{let t=await x.account(e);i(t),t.Restriction.Frozen&&(t.Restriction.Until&&m(nt(new Date(t.Restriction.Until))),_(t.Restriction.AppealURL||``))}catch(e){o(b(e))}finally{c(!1)}}if((0,g.useEffect)(()=>{v()},[e]),a)return(0,V.jsx)(Ke,{children:a});if(!r)return(0,V.jsx)(Ye,{label:n(s?`account.loadingDetail`:`account.waitingData`)});let y=r.Account;return(0,V.jsx)(He,{title:n(`account.detailTitle`,{id:y.ID}),eyebrow:n(`account.profile`),actions:(0,V.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/accounts`),children:[(0,V.jsx)(M,{size:15}),` `,n(`common.backToList`)]}),children:(0,V.jsx)(We,{main:(0,V.jsxs)(`div`,{className:`stacked-sections`,children:[(0,V.jsxs)(`section`,{className:`entity-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`entity-title`,children:Le(y)}),(0,V.jsxs)(`div`,{className:`entity-subtitle`,children:[Ie(y.Username)||n(`account.noUsername`),` · `,Fe(y.Phone)||n(`account.noPhone`)]})]}),(0,V.jsxs)(`div`,{className:`entity-badges`,children:[y.PremiumUntil>0?(0,V.jsx)(G,{tone:`good`,children:n(`account.premium`)}):(0,V.jsx)(G,{children:n(`account.notPremium`)}),r.Verified?(0,V.jsx)(G,{tone:`good`,children:n(`common.verified`)}):(0,V.jsx)(G,{children:n(`account.notVerified`)}),y.Frozen?(0,V.jsx)(G,{tone:`danger`,children:n(`account.accountFrozen`)}):(0,V.jsx)(G,{children:n(`account.accountActive`)})]})]}),(0,V.jsxs)(`div`,{className:`summary-grid`,children:[(0,V.jsx)(J,{label:n(`account.userID`),value:String(y.ID),mono:!0}),(0,V.jsx)(J,{label:n(`account.lastActive`),value:Be(r.LastSeenAt)||`-`}),(0,V.jsx)(J,{label:n(`account.premiumUntil`),value:y.PremiumUntil>0?Be(y.PremiumUntil):n(`common.none`)}),(0,V.jsx)(J,{label:n(`account.starsBalance`),value:`${r.StarsBalance} / ${r.StarsGranted?n(`account.startingGrantApplied`):n(`account.startingGrantPending`)}`}),(0,V.jsx)(J,{label:n(`common.updatedAt`),value:ze(y.UpdatedAt)||`-`}),(0,V.jsx)(J,{label:n(`account.activeSessions`),value:String(r.Authorizations.length)}),(0,V.jsx)(J,{label:n(`account.accountFlags`),value:`support=${r.Support} bot=${r.Bot}`}),(0,V.jsx)(J,{label:n(`account.restriction`),value:r.HasRestriction?r.Restriction.Reason||n(`account.restricted`):n(`common.none`)}),(0,V.jsx)(J,{label:n(`account.freezeSince`),value:r.Restriction.Since?ze(r.Restriction.Since):n(`common.none`)}),(0,V.jsx)(J,{label:n(`account.freezeUntil`),value:r.Restriction.Until?ze(r.Restriction.Until):n(`common.none`)}),(0,V.jsx)(J,{label:n(`account.freezeAppealURL`),value:r.Restriction.AppealURL||n(`common.none`)}),(0,V.jsx)(J,{label:n(`account.createdAt`),value:ze(y.CreatedAt)||`-`})]}),r.About&&(0,V.jsx)(`p`,{className:`about-text`,children:r.About}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:n(`account.authorizationsTitle`),text:n(`account.authorizationsCount`,{count:r.Authorizations.length})}),(0,V.jsx)(et,{rows:r.Authorizations,userID:y.ID,onDone:v})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:n(`account.recentAdminOps`),text:n(`account.recent30Audit`)}),(0,V.jsx)(qe,{rows:r.AuditLogs})]})]}),side:(0,V.jsxs)(`section`,{className:`action-dock`,children:[(0,V.jsx)(`div`,{className:`dock-title`,children:n(`account.actionDock`)}),(0,V.jsxs)(`label`,{className:`duration-field`,children:[(0,V.jsx)(`span`,{children:n(`account.freezeUntil`)}),(0,V.jsx)(`input`,{"aria-label":n(`account.freezeUntilAria`),value:p,onChange:e=>m(e.target.value),type:`datetime-local`})]}),(0,V.jsxs)(`label`,{className:`duration-field`,children:[(0,V.jsx)(`span`,{children:n(`account.freezeAppealURL`)}),(0,V.jsx)(`input`,{"aria-label":n(`account.freezeAppealURLAria`),value:h,onChange:e=>_(e.target.value),type:`url`,placeholder:`https://...`})]}),(0,V.jsx)($e,{label:y.Frozen?n(`account.updateFreeze`):n(`account.freezeAccount`),icon:(0,V.jsx)(O,{size:15}),path:`/api/actions/set-frozen`,payload:()=>({user_id:y.ID,frozen:!0,freeze_until:new Date(p).toISOString(),freeze_appeal_url:h.trim()}),onDone:v}),y.Frozen&&(0,V.jsx)($e,{label:n(`account.unfreezeAccount`),icon:(0,V.jsx)(O,{size:15}),path:`/api/actions/set-frozen`,payload:()=>({user_id:y.ID,frozen:!1}),onDone:v}),(0,V.jsxs)(`label`,{className:`duration-field`,children:[(0,V.jsx)(`span`,{children:n(`account.premiumMonths`)}),(0,V.jsx)(`input`,{"aria-label":n(`account.premiumMonthsAria`),value:l,onChange:e=>u(e.target.value),type:`number`,min:`1`,max:`120`})]}),(0,V.jsxs)(`div`,{className:`action-stack`,children:[(0,V.jsx)($e,{label:n(`account.setPremium`),icon:(0,V.jsx)(j,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:y.ID,months:W(l)}),onDone:v}),(0,V.jsx)($e,{label:n(`account.clearPremium`),icon:(0,V.jsx)(j,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:y.ID,months:0}),onDone:v}),(0,V.jsxs)(`label`,{className:`duration-field`,children:[(0,V.jsx)(`span`,{children:n(`account.starsAmount`)}),(0,V.jsx)(`input`,{"aria-label":n(`account.starsAmountAria`),value:d,onChange:e=>f(e.target.value),type:`number`,min:`1`,max:`1000000000`})]}),(0,V.jsx)($e,{label:n(`account.grantStars`),icon:(0,V.jsx)(he,{size:15}),tone:`warn`,path:`/api/actions/grant-stars`,payload:()=>({user_id:y.ID,amount:W(d)}),onDone:v}),(0,V.jsx)($e,{label:r.Verified?n(`account.clearVerified`):n(`account.setVerified`),icon:(0,V.jsx)(D,{size:15}),tone:`warn`,path:`/api/actions/set-verified`,payload:()=>({user_id:y.ID,verified:!r.Verified}),onDone:v})]})]})})})}function nt(e){return new Date(e.getTime()-e.getTimezoneOffset()*6e4).toISOString().slice(0,16)}function rt(e){return e.reduce((e,t)=>(e.devices+=t.DeviceCount,t.PremiumUntil>0&&(e.premium+=1),t.Frozen&&(e.frozen+=1),e),{devices:0,premium:0,frozen:0})}function it(e){return e.reduce((e,t)=>(t.Megagroup&&(e.megagroups+=1),t.Broadcast&&(e.broadcasts+=1),t.Verified&&(e.verified+=1),e),{megagroups:0,broadcasts:0,verified:0})}function at({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(`50`),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)({beforeID:0,beforeActiveUS:0}),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``);async function m(e=!1){d(!0),p(``);let t=new URLSearchParams({limit:i});n.trim()?t.set(`q`,n.trim()):e&&(t.set(`before_id`,String(c.beforeID)),t.set(`before_active_us`,String(c.beforeActiveUS)));try{let e=await x.accounts(t);s(e),l({beforeID:e.next_before_id,beforeActiveUS:e.next_before_active_us})}catch(e){p(b(e))}finally{d(!1)}}(0,g.useEffect)(()=>{m(!1)},[]);let h=rt(o?.rows??[]);return(0,V.jsxs)(He,{title:t(`account.pageTitle`),eyebrow:o?.listing===!1?t(`account.queryResults`):t(`account.recentActive`),actions:(0,V.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>m(!1),disabled:u,children:[(0,V.jsx)(de,{size:15}),` `,t(`common.refresh`)]}),children:[f&&(0,V.jsx)(Ke,{children:f}),(0,V.jsxs)(`div`,{className:`metric-row`,children:[(0,V.jsx)(q,{label:t(`account.currentPage`),value:String(o?.rows.length??0)}),(0,V.jsx)(q,{label:t(`account.onlineDevices`),value:String(h.devices)}),(0,V.jsx)(q,{label:t(`account.premium`),value:String(h.premium),tone:`good`}),(0,V.jsx)(q,{label:t(`account.frozen`),value:String(h.frozen),tone:h.frozen>0?`danger`:`neutral`})]}),(0,V.jsx)(Ue,{children:(0,V.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,V.jsxs)(`label`,{className:`searchbox`,children:[(0,V.jsx)(B,{size:15}),(0,V.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`account.searchPlaceholder`)})]}),(0,V.jsxs)(`label`,{className:`field-inline`,children:[(0,V.jsx)(`span`,{children:t(`common.limit`)}),(0,V.jsx)(`input`,{className:`small-input`,value:i,onChange:e=>a(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,V.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:u,children:[u?(0,V.jsx)(A,{size:15,className:`spin`}):(0,V.jsx)(B,{size:15}),` `,t(`common.search`)]}),o?.listing&&o.has_more&&(0,V.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),disabled:u,children:[(0,V.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:t(`account.userID`)}),(0,V.jsx)(`th`,{children:t(`account.phone`)}),(0,V.jsx)(`th`,{children:t(`common.username`)}),(0,V.jsx)(`th`,{children:t(`common.name`)}),(0,V.jsx)(`th`,{children:t(`common.device`)}),(0,V.jsx)(`th`,{children:t(`account.lastActive`)}),(0,V.jsx)(`th`,{children:t(`account.premium`)}),(0,V.jsx)(`th`,{children:t(`common.verified`)}),(0,V.jsx)(`th`,{children:t(`account.frozen`)}),(0,V.jsx)(`th`,{children:t(`common.updatedAt`)}),(0,V.jsx)(`th`,{})]})}),(0,V.jsxs)(`tbody`,{children:[o?.rows.map(n=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{className:`mono`,children:n.ID}),(0,V.jsx)(`td`,{children:Fe(n.Phone)}),(0,V.jsx)(`td`,{children:Ie(n.Username)}),(0,V.jsx)(`td`,{children:Le(n)}),(0,V.jsx)(`td`,{children:n.DeviceCount}),(0,V.jsx)(`td`,{children:ze(n.LastActiveAt)}),(0,V.jsx)(`td`,{children:n.PremiumUntil>0?(0,V.jsxs)(G,{tone:`good`,children:[t(`account.premium`),` `,Be(n.PremiumUntil)]}):(0,V.jsx)(G,{children:t(`common.none`)})}),(0,V.jsx)(`td`,{children:n.Verified?(0,V.jsx)(G,{tone:`good`,children:t(`common.verified`)}):(0,V.jsx)(G,{children:t(`account.notVerified`)})}),(0,V.jsx)(`td`,{children:n.Frozen?(0,V.jsx)(G,{tone:`danger`,children:t(`account.frozen`)}):(0,V.jsx)(G,{children:t(`common.normal`)})}),(0,V.jsx)(`td`,{children:ze(n.UpdatedAt)}),(0,V.jsx)(`td`,{children:(0,V.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/accounts/${n.ID}`),children:[t(`common.detail`),` `,(0,V.jsx)(I,{size:14})]})})]},n.ID)),(!o||o.rows.length===0)&&(0,V.jsx)(Je,{colSpan:11})]})]})})]})}function ot({id:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``);async function s(){o(``);try{i(await x.channel(e))}catch(e){o(b(e))}}if((0,g.useEffect)(()=>{s()},[e]),a)return(0,V.jsx)(Ke,{children:a});if(!r)return(0,V.jsx)(Ye,{label:n(`channel.loadingDetail`)});let c=r.Channel;return(0,V.jsx)(He,{title:`${Re(c,n)} #${c.ID}`,eyebrow:n(`channel.detailProfile`),actions:(0,V.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/channels`),children:[(0,V.jsx)(M,{size:15}),` `,n(`common.backToList`)]}),children:(0,V.jsx)(We,{main:(0,V.jsxs)(`div`,{className:`stacked-sections`,children:[(0,V.jsxs)(`section`,{className:`entity-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`entity-title`,children:c.Title||`-`}),(0,V.jsxs)(`div`,{className:`entity-subtitle`,children:[Ie(c.Username)||n(`account.noUsername`),` · `,n(`channel.creator`,{id:c.CreatorUserID})]})]}),(0,V.jsxs)(`div`,{className:`entity-badges`,children:[(0,V.jsx)(G,{children:Re(c,n)}),c.Verified?(0,V.jsx)(G,{tone:`good`,children:n(`common.verified`)}):(0,V.jsx)(G,{children:n(`account.notVerified`)}),c.Deleted?(0,V.jsx)(G,{tone:`danger`,children:n(`common.deleted`)}):(0,V.jsx)(G,{children:n(`common.valid`)})]})]}),(0,V.jsxs)(`div`,{className:`summary-grid`,children:[(0,V.jsx)(J,{label:n(`channel.channelID`),value:String(c.ID),mono:!0}),(0,V.jsx)(J,{label:`access_hash`,value:String(c.AccessHash),mono:!0}),(0,V.jsx)(J,{label:n(`common.members`),value:`${c.ParticipantsCount} / ${n(`common.admins`)} ${c.AdminsCount}`}),(0,V.jsx)(J,{label:n(`channel.governance`),value:n(`channel.governanceValue`,{banned:c.BannedCount,kicked:c.KickedCount})}),(0,V.jsx)(J,{label:n(`channel.flags`),value:`broadcast=${c.Broadcast} megagroup=${c.Megagroup} forum=${c.Forum}`}),(0,V.jsx)(J,{label:`top / pinned / PTS`,value:`${c.TopMessageID} / ${c.PinnedMessageID} / ${c.PTS}`}),(0,V.jsx)(J,{label:n(`account.createdAt`),value:Be(c.Date)||`-`}),(0,V.jsx)(J,{label:n(`common.updatedAt`),value:ze(c.UpdatedAt)||`-`})]}),c.About&&(0,V.jsx)(`p`,{className:`about-text`,children:c.About}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:n(`account.recentAdminOps`),text:n(`account.recent30Audit`)}),(0,V.jsx)(qe,{rows:r.AuditLogs})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:n(`channel.rawRow`),text:n(`channel.rawRowText`)}),(0,V.jsx)(Xe,{value:r.ChannelJSON})]})]}),side:(0,V.jsxs)(`section`,{className:`action-dock`,children:[(0,V.jsx)(`div`,{className:`dock-title`,children:n(`channel.actionDock`)}),(0,V.jsx)($e,{label:c.Verified?n(`channel.clearVerified`):n(`channel.setVerified`),icon:(0,V.jsx)(D,{size:15}),tone:`warn`,path:`/api/actions/set-channel-verified`,payload:()=>({channel_id:c.ID,verified:!c.Verified}),onDone:s})]})})})}function st({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(`50`),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)({beforeID:0,beforeUpdatedUS:0}),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``);async function m(e=!1){d(!0),p(``);let t=new URLSearchParams({limit:i});n.trim()?t.set(`q`,n.trim()):e&&(t.set(`before_id`,String(c.beforeID)),t.set(`before_updated_us`,String(c.beforeUpdatedUS)));try{let e=await x.channels(t);s(e),l({beforeID:e.next_before_id,beforeUpdatedUS:e.next_before_updated_us})}catch(e){p(b(e))}finally{d(!1)}}(0,g.useEffect)(()=>{m(!1)},[]);let h=it(o?.rows??[]);return(0,V.jsxs)(He,{title:t(`channel.pageTitle`),eyebrow:o?.listing===!1?t(`account.queryResults`):t(`channel.recentUpdated`),actions:(0,V.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>m(!1),disabled:u,children:[(0,V.jsx)(de,{size:15}),` `,t(`common.refresh`)]}),children:[f&&(0,V.jsx)(Ke,{children:f}),(0,V.jsxs)(`div`,{className:`metric-row`,children:[(0,V.jsx)(q,{label:t(`channel.currentPage`),value:String(o?.rows.length??0)}),(0,V.jsx)(q,{label:t(`channel.megagroups`),value:String(h.megagroups)}),(0,V.jsx)(q,{label:t(`channel.broadcasts`),value:String(h.broadcasts)}),(0,V.jsx)(q,{label:t(`channel.verifiedCount`),value:String(h.verified),tone:`good`})]}),(0,V.jsx)(Ue,{children:(0,V.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,V.jsxs)(`label`,{className:`searchbox`,children:[(0,V.jsx)(B,{size:15}),(0,V.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`channel.searchPlaceholder`)})]}),(0,V.jsxs)(`label`,{className:`field-inline`,children:[(0,V.jsx)(`span`,{children:t(`common.limit`)}),(0,V.jsx)(`input`,{className:`small-input`,value:i,onChange:e=>a(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,V.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:u,children:[u?(0,V.jsx)(A,{size:15,className:`spin`}):(0,V.jsx)(B,{size:15}),` `,t(`common.search`)]}),o?.listing&&o.has_more&&(0,V.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),disabled:u,children:[(0,V.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:t(`channel.channelID`)}),(0,V.jsx)(`th`,{children:t(`channel.kind`)}),(0,V.jsx)(`th`,{children:t(`common.username`)}),(0,V.jsx)(`th`,{children:t(`channel.title`)}),(0,V.jsx)(`th`,{children:t(`common.members`)}),(0,V.jsx)(`th`,{children:t(`common.admins`)}),(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:t(`common.verified`)}),(0,V.jsx)(`th`,{children:t(`common.updatedAt`)}),(0,V.jsx)(`th`,{})]})}),(0,V.jsxs)(`tbody`,{children:[o?.rows.map(n=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{className:`mono`,children:n.ID}),(0,V.jsx)(`td`,{children:Re(n,t)}),(0,V.jsx)(`td`,{children:Ie(n.Username)}),(0,V.jsx)(`td`,{children:n.Title}),(0,V.jsx)(`td`,{children:n.ParticipantsCount}),(0,V.jsx)(`td`,{children:n.AdminsCount}),(0,V.jsx)(`td`,{children:n.PTS}),(0,V.jsx)(`td`,{children:n.Verified?(0,V.jsx)(G,{tone:`good`,children:t(`common.verified`)}):(0,V.jsx)(G,{children:t(`account.notVerified`)})}),(0,V.jsx)(`td`,{children:ze(n.UpdatedAt)}),(0,V.jsx)(`td`,{children:(0,V.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/channels/${n.ID}`),children:[t(`common.detail`),` `,(0,V.jsx)(I,{size:14})]})})]},n.ID)),(!o||o.rows.length===0)&&(0,V.jsx)(Je,{colSpan:10})]})]})})]})}function ct({navigate:e}){let{t}=U();return(0,V.jsxs)(`div`,{className:`dashboard-layout`,children:[(0,V.jsxs)(`section`,{className:`overview-band`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`eyebrow`,children:t(`dashboard.eyebrow`)}),(0,V.jsx)(`h2`,{children:t(`dashboard.title`)})]}),(0,V.jsxs)(`div`,{className:`overview-metrics`,children:[(0,V.jsx)(K,{label:t(`dashboard.readPath`),value:t(`dashboard.readPathValue`),tone:`neutral`}),(0,V.jsx)(K,{label:t(`dashboard.writePath`),value:`Admin API`,tone:`good`}),(0,V.jsx)(K,{label:t(`dashboard.executionPolicy`),value:t(`dashboard.dryRunFirst`),tone:`warn`})]})]}),(0,V.jsxs)(`div`,{className:`command-grid`,children:[(0,V.jsx)(lt,{icon:(0,V.jsx)(ve,{}),title:t(`route.accounts`),text:t(`dashboard.accountsText`),href:`/accounts`,navigate:e}),(0,V.jsx)(lt,{icon:(0,V.jsx)(pe,{}),title:t(`route.channels`),text:t(`dashboard.channelsText`),href:`/channels`,navigate:e}),(0,V.jsx)(lt,{icon:(0,V.jsx)(ce,{}),title:t(`route.messages`),text:t(`dashboard.messagesText`),href:`/messages`,navigate:e})]}),(0,V.jsxs)(`section`,{className:`work-strip`,children:[(0,V.jsxs)(`div`,{className:`strip-item`,children:[(0,V.jsx)(k,{size:16}),(0,V.jsx)(`span`,{children:t(`dashboard.strip.dryRun`)})]}),(0,V.jsxs)(`div`,{className:`strip-item`,children:[(0,V.jsx)(ae,{size:16}),(0,V.jsx)(`span`,{children:t(`dashboard.strip.token`)})]}),(0,V.jsxs)(`div`,{className:`strip-item`,children:[(0,V.jsx)(L,{size:16}),(0,V.jsx)(`span`,{children:t(`dashboard.strip.pagination`)})]}),(0,V.jsxs)(`div`,{className:`strip-item`,children:[(0,V.jsx)(te,{size:16}),(0,V.jsx)(`span`,{children:t(`dashboard.strip.snapshot`)})]})]})]})}function lt({icon:e,title:t,text:n,href:r,navigate:i}){return(0,V.jsxs)(je,{className:`launcher`,href:r,navigate:i,children:[(0,V.jsx)(`span`,{className:`launcher-icon`,children:e}),(0,V.jsxs)(`span`,{className:`launcher-copy`,children:[(0,V.jsx)(`strong`,{children:t}),(0,V.jsx)(`span`,{children:n})]}),(0,V.jsx)(I,{size:16})]})}function ut({channelID:e,msgID:t,navigate:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``);async function c(){s(``);try{a(await x.groupMessage(e,t))}catch(e){s(b(e))}}if((0,g.useEffect)(()=>{c()},[e,t]),o)return(0,V.jsx)(Ke,{children:o});if(!i)return(0,V.jsx)(Ye,{label:r(`common.loading`)});let l=i.Message;return(0,V.jsx)(He,{title:r(`messages.groupDetailTitle`,{id:l.ID}),eyebrow:r(`messages.detailEyebrow`),actions:(0,V.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/groups`),children:[(0,V.jsx)(M,{size:15}),` `,r(`messages.backGroup`)]}),children:(0,V.jsxs)(`div`,{className:`stacked-sections`,children:[(0,V.jsxs)(`section`,{className:`entity-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`entity-title`,children:r(`messages.channelGroupTitle`,{id:l.ChannelID})}),(0,V.jsx)(`div`,{className:`entity-subtitle`,children:r(`messages.senderSubtitle`,{sender:l.SenderUserID,date:Be(l.Date)})})]}),(0,V.jsxs)(`div`,{className:`entity-badges`,children:[l.Deleted?(0,V.jsx)(G,{tone:`danger`,children:r(`common.deleted`)}):(0,V.jsx)(G,{children:r(`common.survived`)}),l.Pinned&&(0,V.jsx)(G,{tone:`warn`,children:r(`messages.pinned`)}),l.Post&&(0,V.jsx)(G,{children:r(`messages.channelPost`)}),(0,V.jsxs)(G,{children:[`pts `,l.PTS]})]})]}),(0,V.jsxs)(`div`,{className:`summary-grid`,children:[(0,V.jsx)(J,{label:r(`common.messageId`),value:String(l.ID),mono:!0}),(0,V.jsx)(J,{label:r(`messages.channelGroup`),value:String(l.ChannelID),mono:!0}),(0,V.jsx)(J,{label:`From Peer`,value:`${l.FromPeerType}:${l.FromPeerID}`,mono:!0}),(0,V.jsx)(J,{label:r(`common.views`),value:String(l.ViewsCount)})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.channelMessageRow`),text:r(`messages.channelMessagesSnapshot`)}),(0,V.jsx)(Xe,{value:i.MessageJSON})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.channelRow`),text:r(`messages.channelSnapshot`)}),(0,V.jsx)(Xe,{value:i.ChannelJSON})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.channelUpdateEvents`),text:r(`messages.channelEventsSource`)}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:r(`common.count`)}),(0,V.jsx)(`th`,{children:r(`common.type`)}),(0,V.jsx)(`th`,{children:r(`common.messageId`)}),(0,V.jsx)(`th`,{children:r(`common.sender`)}),(0,V.jsx)(`th`,{children:r(`common.time`)})]})}),(0,V.jsxs)(`tbody`,{children:[i.UpdateEvents.map(e=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{children:e.PTS}),(0,V.jsx)(`td`,{children:e.PTSCount}),(0,V.jsx)(`td`,{children:e.Type}),(0,V.jsx)(`td`,{children:e.MessageID}),(0,V.jsx)(`td`,{children:e.SenderUserID}),(0,V.jsx)(`td`,{children:Be(e.Date)})]},`${e.PTS}-${e.Type}-${e.MessageID}`)),i.UpdateEvents.length===0&&(0,V.jsx)(Je,{colSpan:6})]})]})})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.eventJson`)}),(0,V.jsxs)(`div`,{className:`raw-grid`,children:[i.UpdateEvents.map(e=>(0,V.jsx)(Xe,{value:e.JSON},`${e.PTS}-${e.Type}-json`)),i.UpdateEvents.length===0&&(0,V.jsx)(`div`,{className:`empty-panel`,children:r(`common.noResults`)})]})]})]})})}function dt({label:e,value:t,onChange:n}){let{t:r}=U(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)([]),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(){l(!0),d(``);let e=new URLSearchParams({limit:`20`});i.trim()&&e.set(`q`,i.trim());try{s((await x.accounts(e)).rows)}catch(e){d(b(e))}finally{l(!1)}}return(0,g.useEffect)(()=>{f()},[]),(0,V.jsxs)(`div`,{className:`entity-picker`,children:[(0,V.jsxs)(`div`,{className:`picker-head`,children:[(0,V.jsx)(`span`,{children:e}),t?(0,V.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,V.jsx)(ye,{size:13}),` `,r(`common.clear`)]}):null]}),t?(0,V.jsxs)(`div`,{className:`selected-entity`,children:[(0,V.jsx)(P,{size:15}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:Le(t)}),(0,V.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,V.jsx)(`span`,{children:Ie(t.Username)||Fe(t.Phone)||`-`})]}):null,(0,V.jsxs)(`div`,{className:`picker-search`,children:[(0,V.jsx)(B,{size:15}),(0,V.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),f())},placeholder:r(`picker.userPlaceholder`)}),(0,V.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:f,disabled:c,children:c?(0,V.jsx)(A,{size:14,className:`spin`}):r(`common.search`)})]}),u&&(0,V.jsx)(`div`,{className:`picker-error`,children:u}),(0,V.jsxs)(`div`,{className:`picker-results`,children:[o.map(e=>(0,V.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,V.jsx)(`span`,{className:`mono`,children:e.ID}),(0,V.jsx)(`strong`,{children:Le(e)}),(0,V.jsx)(`span`,{children:Ie(e.Username)||Fe(e.Phone)||`-`}),e.Verified?(0,V.jsx)(G,{tone:`good`,children:r(`picker.verified`)}):(0,V.jsx)(G,{children:r(`picker.regular`)})]},e.ID)),o.length===0&&!c?(0,V.jsx)(`div`,{className:`picker-empty`,children:r(`common.noResults`)}):null]})]})}function ft({label:e,value:t,onChange:n}){let{t:r}=U(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)([]),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(){l(!0),d(``);let e=new URLSearchParams({limit:`20`});i.trim()&&e.set(`q`,i.trim());try{s((await x.channels(e)).rows)}catch(e){d(b(e))}finally{l(!1)}}return(0,g.useEffect)(()=>{f()},[]),(0,V.jsxs)(`div`,{className:`entity-picker`,children:[(0,V.jsxs)(`div`,{className:`picker-head`,children:[(0,V.jsx)(`span`,{children:e}),t?(0,V.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,V.jsx)(ye,{size:13}),` `,r(`common.clear`)]}):null]}),t?(0,V.jsxs)(`div`,{className:`selected-entity`,children:[(0,V.jsx)(P,{size:15}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:t.Title||`-`}),(0,V.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,V.jsx)(`span`,{children:Ie(t.Username)||Re(t,r)})]}):null,(0,V.jsxs)(`div`,{className:`picker-search`,children:[(0,V.jsx)(B,{size:15}),(0,V.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),f())},placeholder:r(`picker.channelPlaceholder`)}),(0,V.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:f,disabled:c,children:c?(0,V.jsx)(A,{size:14,className:`spin`}):r(`common.search`)})]}),u&&(0,V.jsx)(`div`,{className:`picker-error`,children:u}),(0,V.jsxs)(`div`,{className:`picker-results`,children:[o.map(e=>(0,V.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,V.jsx)(`span`,{className:`mono`,children:e.ID}),(0,V.jsx)(`strong`,{children:e.Title||`-`}),(0,V.jsx)(`span`,{children:Ie(e.Username)||Re(e,r)}),e.Verified?(0,V.jsx)(G,{tone:`good`,children:r(`picker.verified`)}):(0,V.jsx)(G,{children:Re(e,r)})]},e.ID)),o.length===0&&!c?(0,V.jsx)(`div`,{className:`picker-empty`,children:r(`common.noResults`)}):null]})]})}function pt({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(`100`),[u,d]=(0,g.useState)(null),[f,p]=(0,g.useState)(``);async function m(e=!1){if(p(``),!n){p(t(`messages.selectChannel`));return}let r=new URLSearchParams({channel_id:String(n.ID),limit:c});if(e&&u?.rows.length){let e=u.rows[u.rows.length-1];r.set(`before_date`,String(e.Date)),r.set(`before_id`,String(e.ID)),a(String(e.Date)),s(String(e.ID))}else i&&r.set(`before_date`,i),o&&r.set(`before_id`,o);try{d(await x.groupMessages(r))}catch(e){p(b(e))}}function h(e){r(e),a(``),s(``),d(null)}let _=u?.rows??[];return(0,V.jsxs)(He,{title:t(`messages.groupTitle`),eyebrow:t(`messages.groupEyebrow`),children:[f&&(0,V.jsx)(Ke,{children:f}),(0,V.jsxs)(Ue,{children:[(0,V.jsx)(`div`,{className:`message-selector-grid single`,children:(0,V.jsx)(ft,{label:t(`messages.channelGroup`),value:n,onChange:h})}),(0,V.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,V.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),placeholder:t(`messages.beforeDatePlaceholder`)}),(0,V.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:t(`messages.beforeIDPlaceholder`)}),(0,V.jsx)(`input`,{className:`small-input`,value:c,onChange:e=>l(e.target.value),placeholder:t(`messages.limitPlaceholder`)}),(0,V.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,V.jsx)(B,{size:15}),` `,t(`messages.searchMessages`)]}),_.length?(0,V.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),children:[(0,V.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]}):null]})]}),(0,V.jsxs)(`div`,{className:`metric-row`,children:[(0,V.jsx)(q,{label:t(`messages.currentPage`),value:String(_.length)}),(0,V.jsx)(q,{label:t(`messages.mediaCount`),value:String(_.filter(e=>e.Media&&e.Media!==`{}`).length)}),(0,V.jsx)(q,{label:t(`messages.channelPosts`),value:String(_.filter(e=>e.Post).length)}),(0,V.jsx)(q,{label:t(`messages.channelGroup`),value:n?`${n.Title||Re(n,t)} (${n.ID})`:`-`})]}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:t(`common.messageId`)}),(0,V.jsx)(`th`,{children:t(`common.time`)}),(0,V.jsx)(`th`,{children:t(`common.sender`)}),(0,V.jsx)(`th`,{children:`From Peer`}),(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:t(`common.views`)}),(0,V.jsx)(`th`,{children:t(`common.status`)}),(0,V.jsx)(`th`,{children:t(`messages.body`)}),(0,V.jsx)(`th`,{})]})}),(0,V.jsxs)(`tbody`,{children:[_.map(n=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{className:`mono`,children:n.ID}),(0,V.jsx)(`td`,{children:Be(n.Date)}),(0,V.jsx)(`td`,{className:`mono`,children:n.SenderUserID}),(0,V.jsxs)(`td`,{className:`mono`,children:[n.FromPeerType,`:`,n.FromPeerID]}),(0,V.jsx)(`td`,{children:n.PTS}),(0,V.jsx)(`td`,{children:n.ViewsCount}),(0,V.jsx)(`td`,{children:n.Deleted?(0,V.jsx)(G,{tone:`danger`,children:t(`common.deleted`)}):n.Pinned?(0,V.jsx)(G,{tone:`warn`,children:t(`messages.pinned`)}):(0,V.jsx)(G,{children:t(`common.survived`)})}),(0,V.jsx)(`td`,{className:`truncate`,children:n.Body}),(0,V.jsx)(`td`,{children:(0,V.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/groups/detail?channel_id=${n.ChannelID}&msg_id=${n.ID}`),children:[t(`common.detail`),` `,(0,V.jsx)(I,{size:14})]})})]},`${n.ChannelID}-${n.ID}`)),_.length===0&&(0,V.jsx)(Je,{colSpan:9})]})]})})]})}function mt({ownerUserID:e,msgID:t,navigate:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``);async function c(){s(``);try{a(await x.message(e,t))}catch(e){s(b(e))}}if((0,g.useEffect)(()=>{c()},[e,t]),o)return(0,V.jsx)(Ke,{children:o});if(!i)return(0,V.jsx)(Ye,{label:r(`common.loading`)});let l=i.Message;return(0,V.jsx)(He,{title:r(`messages.privateDetailTitle`,{id:l.BoxID}),eyebrow:r(`messages.detailEyebrow`),actions:(0,V.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/private`),children:[(0,V.jsx)(M,{size:15}),` `,r(`messages.backPrivate`)]}),children:(0,V.jsx)(We,{main:(0,V.jsxs)(`div`,{className:`stacked-sections`,children:[(0,V.jsxs)(`section`,{className:`entity-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`entity-title`,children:r(`messages.ownerPeerTitle`,{owner:l.OwnerUserID,peer:l.PeerID})}),(0,V.jsx)(`div`,{className:`entity-subtitle`,children:r(`messages.senderSubtitle`,{sender:l.FromUserID,date:Be(l.Date)})})]}),(0,V.jsxs)(`div`,{className:`entity-badges`,children:[l.Deleted?(0,V.jsx)(G,{tone:`danger`,children:r(`common.deleted`)}):(0,V.jsx)(G,{children:r(`common.survived`)}),(0,V.jsxs)(G,{children:[`pts `,l.PTS]}),(0,V.jsx)(G,{children:l.Outgoing?r(`messages.outgoing`):r(`messages.incoming`)})]})]}),(0,V.jsxs)(`div`,{className:`summary-grid`,children:[(0,V.jsx)(J,{label:r(`messages.boxID`),value:String(l.BoxID),mono:!0}),(0,V.jsx)(J,{label:r(`messages.privateMessageID`),value:String(l.PrivateMessageID),mono:!0}),(0,V.jsx)(J,{label:r(`messages.messageSender`),value:String(l.MessageSenderID),mono:!0}),(0,V.jsx)(J,{label:r(`common.time`),value:Be(l.Date)})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.messageBox`),text:r(`messages.messageBoxesSnapshot`)}),(0,V.jsx)(Xe,{value:i.MessageJSON})]}),(0,V.jsxs)(`div`,{className:`raw-grid`,children:[(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.dialogRow`),text:r(`messages.dialogSnapshot`)}),(0,V.jsx)(Xe,{value:i.DialogJSON})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.privateRow`),text:r(`messages.privateSnapshot`)}),(0,V.jsx)(Xe,{value:i.PrivateJSON})]})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.userUpdateEvents`),text:r(`messages.userEventsSource`)}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:r(`common.count`)}),(0,V.jsx)(`th`,{children:r(`common.type`)}),(0,V.jsx)(`th`,{children:r(`common.time`)})]})}),(0,V.jsxs)(`tbody`,{children:[i.UpdateEvents.map(e=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{children:e.PTS}),(0,V.jsx)(`td`,{children:e.PTSCount}),(0,V.jsx)(`td`,{children:e.Type}),(0,V.jsx)(`td`,{children:Be(e.Date)})]},`${e.PTS}-${e.Type}`)),i.UpdateEvents.length===0&&(0,V.jsx)(Je,{colSpan:4})]})]})})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.dispatchOutbox`),text:r(`messages.outboxSource`)}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:`ID`}),(0,V.jsx)(`th`,{children:r(`account.userID`)}),(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:r(`common.type`)}),(0,V.jsx)(`th`,{children:r(`common.status`)}),(0,V.jsx)(`th`,{children:r(`messages.attempts`)}),(0,V.jsx)(`th`,{children:r(`common.updatedAt`)})]})}),(0,V.jsxs)(`tbody`,{children:[i.Outbox.map(e=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{children:e.ID}),(0,V.jsx)(`td`,{children:e.TargetUserID}),(0,V.jsx)(`td`,{children:e.PTS}),(0,V.jsx)(`td`,{children:e.EventType}),(0,V.jsx)(`td`,{children:e.Status}),(0,V.jsx)(`td`,{children:e.Attempts}),(0,V.jsx)(`td`,{children:ze(e.UpdatedAt)})]},e.ID)),i.Outbox.length===0&&(0,V.jsx)(Je,{colSpan:7})]})]})})]})]}),side:(0,V.jsxs)(`section`,{className:`action-dock`,children:[(0,V.jsx)(`div`,{className:`dock-title`,children:r(`common.operations`)}),(0,V.jsx)($e,{label:r(`messages.deleteThis`),icon:(0,V.jsx)(ge,{size:15}),path:`/api/actions/delete-messages`,payload:()=>({owner_user_id:l.OwnerUserID,peer_id:l.PeerID,ids:[l.BoxID],revoke:!0}),onDone:c})]})})})}function ht({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(``),[u,d]=(0,g.useState)(`100`),[f,p]=(0,g.useState)(``),[m,h]=(0,g.useState)(!0),[_,v]=(0,g.useState)(!1),[y,S]=(0,g.useState)(``),[C,w]=(0,g.useState)(`1`),[T,E]=(0,g.useState)(null),[D,O]=(0,g.useState)(``);async function k(e=!1){if(O(``),!n||!i){O(t(`messages.selectPrivatePeers`));return}let r=new URLSearchParams({owner_user_id:String(n.ID),peer_id:String(i.ID),limit:u});if(e&&T?.rows.length){let e=T.rows[T.rows.length-1];r.set(`before_date`,String(e.Date)),r.set(`before_id`,String(e.BoxID)),s(String(e.Date)),l(String(e.BoxID))}else o&&r.set(`before_date`,o),c&&r.set(`before_id`,c);try{E(await x.messages(r))}catch(e){O(b(e))}}function A(e){r(e),s(``),l(``),E(null)}function j(e){a(e),s(``),l(``),E(null)}return(0,V.jsxs)(He,{title:t(`messages.privateTitle`),eyebrow:t(`messages.privateEyebrow`),children:[D&&(0,V.jsx)(Ke,{children:D}),(0,V.jsxs)(Ue,{children:[(0,V.jsxs)(`div`,{className:`message-selector-grid`,children:[(0,V.jsx)(dt,{label:t(`messages.ownerUser`),value:n,onChange:A}),(0,V.jsx)(dt,{label:t(`messages.peerUser`),value:i,onChange:j})]}),(0,V.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),k(!1)},children:[(0,V.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:t(`messages.beforeDatePlaceholder`)}),(0,V.jsx)(`input`,{value:c,onChange:e=>l(e.target.value),placeholder:t(`messages.beforeIDPlaceholder`)}),(0,V.jsx)(`input`,{className:`small-input`,value:u,onChange:e=>d(e.target.value),placeholder:t(`messages.limitPlaceholder`)}),(0,V.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,V.jsx)(B,{size:15}),` `,t(`messages.searchMessages`)]}),T?.rows.length?(0,V.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>k(!0),children:[(0,V.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]}):null]})]}),(0,V.jsxs)(`div`,{className:`metric-row`,children:[(0,V.jsx)(q,{label:t(`messages.currentPage`),value:String(T?.rows.length??0)}),(0,V.jsx)(q,{label:t(`messages.deleted`),value:String((T?.rows??[]).filter(e=>e.Deleted).length),tone:`danger`}),(0,V.jsx)(q,{label:t(`messages.outgoing`),value:String((T?.rows??[]).filter(e=>e.Outgoing).length)}),(0,V.jsx)(q,{label:t(`messages.ownerPeer`),value:n&&i?`${Le(n)} / ${Le(i)}`:`-`})]}),(0,V.jsxs)(`div`,{className:`operation-row`,children:[(0,V.jsxs)(`div`,{className:`operation-box`,children:[(0,V.jsxs)(`div`,{className:`operation-title`,children:[(0,V.jsx)(ge,{size:15}),` `,t(`messages.deleteSelected`)]}),(0,V.jsx)(`input`,{value:f,onChange:e=>p(e.target.value),placeholder:t(`messages.idsPlaceholder`)}),(0,V.jsxs)(`label`,{className:`checkline`,children:[(0,V.jsx)(`input`,{type:`checkbox`,checked:m,onChange:e=>h(e.target.checked)}),` `,t(`messages.revoke`)]}),(0,V.jsx)($e,{path:`/api/actions/delete-messages`,label:t(`messages.previewDelete`),payload:()=>({owner_user_id:n?.ID??0,peer_id:i?.ID??0,ids:Ve(f,t(`messages.msgIDsInvalid`)),revoke:m})})]}),(0,V.jsxs)(`div`,{className:`operation-box`,children:[(0,V.jsxs)(`div`,{className:`operation-title`,children:[(0,V.jsx)(ie,{size:15}),` `,t(`messages.clearHistory`)]}),(0,V.jsx)(`input`,{value:y,onChange:e=>S(e.target.value),placeholder:t(`messages.maxIDPlaceholder`)}),(0,V.jsx)(`input`,{value:C,onChange:e=>w(e.target.value),placeholder:t(`messages.maxBatchesPlaceholder`)}),(0,V.jsxs)(`label`,{className:`checkline`,children:[(0,V.jsx)(`input`,{type:`checkbox`,checked:m,onChange:e=>h(e.target.checked)}),` `,t(`messages.revoke`)]}),(0,V.jsxs)(`label`,{className:`checkline`,children:[(0,V.jsx)(`input`,{type:`checkbox`,checked:_,onChange:e=>v(e.target.checked)}),` `,t(`messages.justClear`)]}),(0,V.jsx)($e,{path:`/api/actions/delete-history`,label:t(`messages.previewClearHistory`),payload:()=>({owner_user_id:n?.ID??0,peer_id:i?.ID??0,max_id:W(y),max_batches:W(C),just_clear:_,revoke:m})})]})]}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:t(`common.messageId`)}),(0,V.jsx)(`th`,{children:t(`common.time`)}),(0,V.jsx)(`th`,{children:t(`common.sender`)}),(0,V.jsx)(`th`,{children:t(`messages.direction`)}),(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:t(`common.status`)}),(0,V.jsx)(`th`,{children:t(`messages.body`)}),(0,V.jsx)(`th`,{})]})}),(0,V.jsxs)(`tbody`,{children:[T?.rows.map(n=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{className:`mono`,children:n.BoxID}),(0,V.jsx)(`td`,{children:Be(n.Date)}),(0,V.jsx)(`td`,{className:`mono`,children:n.FromUserID}),(0,V.jsx)(`td`,{children:n.Outgoing?t(`messages.outgoing`):t(`messages.incoming`)}),(0,V.jsx)(`td`,{children:n.PTS}),(0,V.jsx)(`td`,{children:n.Deleted?(0,V.jsx)(G,{tone:`danger`,children:t(`common.deleted`)}):(0,V.jsx)(G,{children:t(`common.survived`)})}),(0,V.jsx)(`td`,{className:`truncate`,children:n.Body}),(0,V.jsx)(`td`,{children:(0,V.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/private/detail?owner_user_id=${n.OwnerUserID}&msg_id=${n.BoxID}`),children:[t(`common.detail`),` `,(0,V.jsx)(I,{size:14})]})})]},`${n.OwnerUserID}-${n.BoxID}`)),(!T||T.rows.length===0)&&(0,V.jsx)(Je,{colSpan:8})]})]})})]})}var gt=c(o(((e,t)=>{typeof document<`u`&&typeof navigator<`u`&&(function(n,r){typeof e==`object`&&t!==void 0?t.exports=r():typeof define==`function`&&define.amd?define(r):(n=typeof globalThis<`u`?globalThis:n||self,n.lottie=r())})(e,(function(){var n=``,r=!1,i=-999999,a=function(e){r=!!e},o=function(){return r},s=function(e){n=e},c=function(){return n};function l(e){return document.createElement(e)}function u(e,t){var n,r=e.length,i;for(n=0;n1?n[1]=1:n[1]<=0&&(n[1]=0),L(n[0],n[1],n[2])}function te(e,t){var n=ee(e[0]*255,e[1]*255,e[2]*255);return n[2]+=t,n[2]>1?n[2]=1:n[2]<0&&(n[2]=0),L(n[0],n[1],n[2])}function ne(e,t){var n=ee(e[0]*255,e[1]*255,e[2]*255);return n[0]+=t/360,n[0]>1?--n[0]:n[0]<0&&(n[0]+=1),L(n[0],n[1],n[2])}(function(){var e=[],t,n;for(t=0;t<256;t+=1)n=t.toString(16),e[t]=n.length===1?`0`+n:n;return function(t,n,r){return t<0&&(t=0),n<0&&(n=0),r<0&&(r=0),`#`+e[t]+e[n]+e[r]}})();var re=function(e){g=!!e},ie=function(){return g},ae=function(e){_=e},oe=function(){return _},se=function(){return v},ce=function(e){E=e},le=function(){return E},ue=function(e){y=e};function z(e){return document.createElementNS(`http://www.w3.org/2000/svg`,e)}function de(e){"@babel/helpers - typeof";return de=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},de(e)}var B=function(){var e=1,t=[],n,r,i={onmessage:function(){},postMessage:function(e){n({data:e})}},a={postMessage:function(e){i.onmessage({data:e})}};function s(e){if(window.Worker&&window.Blob&&o()){var t=new Blob([`var _workerSelf = self; self.onmessage = `,e.toString()],{type:`text/javascript`}),r=URL.createObjectURL(t);return new Worker(r)}return n=e,i}function c(){r||(r=s(function(e){function t(){function e(t,n){var o,s,c=t.length,l,u,d,f;for(s=0;s=0;--t)if(e[t].ty===`sh`)if(e[t].ks.k.i)a(e[t].ks.k);else for(o=e[t].ks.k.length,r=0;rn[0]?!0:n[0]>e[0]?!1:e[1]>n[1]?!0:n[1]>e[1]?!1:e[2]>n[2]?!0:n[2]>e[2]?!1:null}var s=function(){var e=[4,4,14];function t(e){var t=e.t.d;e.t.d={k:[{s:t,t:0}]}}function n(e){var n,r=e.length;for(n=0;n=0;--n)if(e[n].ty===`sh`)if(e[n].ks.k.i)e[n].ks.k.c=e[n].closed;else for(a=e[n].ks.k.length,i=0;i500)&&(this._imageLoaded(),clearInterval(n)),t+=1}.bind(this),50)}function a(t){var n=r(t,this.assetsPath,this.path),i=z(`image`);b?this.testImageLoaded(i):i.addEventListener(`load`,this._imageLoaded,!1),i.addEventListener(`error`,function(){a.img=e,this._imageLoaded()}.bind(this),!1),i.setAttributeNS(`http://www.w3.org/1999/xlink`,`href`,n),this._elementHelper.append?this._elementHelper.append(i):this._elementHelper.appendChild(i);var a={img:i,assetData:t};return a}function o(t){var n=r(t,this.assetsPath,this.path),i=l(`img`);i.crossOrigin=`anonymous`,i.addEventListener(`load`,this._imageLoaded,!1),i.addEventListener(`error`,function(){a.img=e,this._imageLoaded()}.bind(this),!1),i.src=n;var a={img:i,assetData:t};return a}function s(e){var t={assetData:e},n=r(e,this.assetsPath,this.path);return B.loadData(n,function(e){t.img=e,this._footageLoaded()}.bind(this),function(){t.img={},this._footageLoaded()}.bind(this)),t}function c(e,t){this.imagesLoadedCb=t;var n,r=e.length;for(n=0;nthis.animationData.op&&(this.animationData.op=e.op,this.totalFrames=Math.floor(e.op-this.animationData.ip));var t=this.animationData.layers,n,r=t.length,i=e.layers,a,o=i.length;for(a=0;athis.timeCompleted&&(this.currentFrame=this.timeCompleted),this.trigger(`enterFrame`),this.renderFrame(),this.trigger(`drawnFrame`)},V.prototype.renderFrame=function(){if(!(this.isLoaded===!1||!this.renderer))try{this.expressionsPlugin&&this.expressionsPlugin.resetFrame(),this.renderer.renderFrame(this.currentFrame+this.firstFrame)}catch(e){this.triggerRenderFrameError(e)}},V.prototype.play=function(e){e&&this.name!==e||this.isPaused===!0&&(this.isPaused=!1,this.trigger(`_play`),this.audioController.resume(),this._idle&&(this._idle=!1,this.trigger(`_active`)))},V.prototype.pause=function(e){e&&this.name!==e||this.isPaused===!1&&(this.isPaused=!0,this.trigger(`_pause`),this._idle=!0,this.trigger(`_idle`),this.audioController.pause())},V.prototype.togglePause=function(e){e&&this.name!==e||(this.isPaused===!0?this.play():this.pause())},V.prototype.stop=function(e){e&&this.name!==e||(this.pause(),this.playCount=0,this._completedLoop=!1,this.setCurrentRawFrameValue(0))},V.prototype.getMarkerData=function(e){for(var t,n=0;n=this.totalFrames-1&&this.frameModifier>0?!this.loop||this.playCount===this.loop?this.checkSegments(t>this.totalFrames?t%this.totalFrames:0)||(n=!0,t=this.totalFrames-1):t>=this.totalFrames?(this.playCount+=1,this.checkSegments(t%this.totalFrames)||(this.setCurrentRawFrameValue(t%this.totalFrames),this._completedLoop=!0,this.trigger(`loopComplete`))):this.setCurrentRawFrameValue(t):t<0?this.checkSegments(t%this.totalFrames)||(this.loop&&!(this.playCount--<=0&&this.loop!==!0)?(this.setCurrentRawFrameValue(this.totalFrames+t%this.totalFrames),this._completedLoop?this.trigger(`loopComplete`):this._completedLoop=!0):(n=!0,t=0)):this.setCurrentRawFrameValue(t),n&&(this.setCurrentRawFrameValue(t),this.pause(),this.trigger(`complete`))}},V.prototype.adjustSegment=function(e,t){this.playCount=0,e[1]0&&(this.playSpeed<0?this.setSpeed(-this.playSpeed):this.setDirection(-1)),this.totalFrames=e[0]-e[1],this.timeCompleted=this.totalFrames,this.firstFrame=e[1],this.setCurrentRawFrameValue(this.totalFrames-.001-t)):e[1]>e[0]&&(this.frameModifier<0&&(this.playSpeed<0?this.setSpeed(-this.playSpeed):this.setDirection(1)),this.totalFrames=e[1]-e[0],this.timeCompleted=this.totalFrames,this.firstFrame=e[0],this.setCurrentRawFrameValue(.001+t)),this.trigger(`segmentStart`)},V.prototype.setSegment=function(e,t){var n=-1;this.isPaused&&(this.currentRawFrame+this.firstFramet&&(n=t-e)),this.firstFrame=e,this.totalFrames=t-e,this.timeCompleted=this.totalFrames,n!==-1&&this.goToAndStop(n,!0)},V.prototype.playSegments=function(e,t){if(t&&(this.segments.length=0),be(e[0])===`object`){var n,r=e.length;for(n=0;n=0;--n)t[n].animation.destroy(e)}function T(e,t,n){var r=[].concat([].slice.call(document.getElementsByClassName(`lottie`)),[].slice.call(document.getElementsByClassName(`bodymovin`))),i,a=r.length;for(i=0;i0?n=c:t=c;while(Math.abs(s)>a&&++l=i?g(e,d,t,n):f===0?d:h(e,a,a+c,t,n)}},e}(),Se=function(){function e(e){return e.concat(m(e.length))}return{double:e}}(),Ce=function(){return function(e,t,n){var r=0,i=e,a=m(i),o={newElement:s,release:c};function s(){var e;return r?(--r,e=a[r]):e=t(),e}function c(e){r===i&&(a=Se.double(a),i*=2),n&&n(e),a[r]=e,r+=1}return o}}(),U=function(){function e(){return{addedLength:0,percents:p(`float32`,le()),lengths:p(`float32`,le())}}return Ce(8,e)}(),we=function(){function e(){return{lengths:[],totalLength:0}}function t(e){var t,n=e.lengths.length;for(t=0;t-.001&&o<.001}function n(n,r,i,a,o,s,c,l,u){if(i===0&&s===0&&u===0)return t(n,r,a,o,c,l);var d=e.sqrt(e.pow(a-n,2)+e.pow(o-r,2)+e.pow(s-i,2)),f=e.sqrt(e.pow(c-n,2)+e.pow(l-r,2)+e.pow(u-i,2)),p=e.sqrt(e.pow(c-a,2)+e.pow(l-o,2)+e.pow(u-s,2)),m=d>f?d>p?d-f-p:p-f-d:p>f?p-f-d:f-d-p;return m>-1e-4&&m<1e-4}var r=function(){return function(e,t,n,r){var i=le(),a,o,s,c,l,u=0,d,f=[],p=[],m=U.newElement();for(s=n.length,a=0;ao?-1:1,l=!0;l;)if(r[a]<=o&&r[a+1]>o?(s=(o-r[a])/(r[a+1]-r[a]),l=!1):a+=c,a<0||a>=i-1){if(a===i-1)return n[a];l=!1}return n[a]+(n[a+1]-n[a])*s}function l(t,n,r,i,a,o){var s=c(a,o),l=1-s;return[e.round((l*l*l*t[0]+(s*l*l+l*s*l+l*l*s)*r[0]+(s*s*l+l*s*s+s*l*s)*i[0]+s*s*s*n[0])*1e3)/1e3,e.round((l*l*l*t[1]+(s*l*l+l*s*l+l*l*s)*r[1]+(s*s*l+l*s*s+s*l*s)*i[1]+s*s*s*n[1])*1e3)/1e3]}var u=p(`float32`,8);function d(t,n,r,i,a,o,s){a<0?a=0:a>1&&(a=1);var l=c(a,s);o=o>1?1:o;var d=c(o,s),f,p=t.length,m=1-l,h=1-d,g=m*m*m,_=l*m*m*3,v=l*l*m*3,y=l*l*l,b=m*m*h,x=l*m*h+m*l*h+m*m*d,S=l*l*h+m*l*d+l*m*d,C=l*l*d,w=m*h*h,T=l*h*h+m*d*h+m*h*d,E=l*d*h+m*d*d+l*h*d,D=l*d*d,O=h*h*h,k=d*h*h+h*d*h+h*h*d,A=d*d*h+h*d*d+d*h*d,j=d*d*d;for(f=0;f=l.t-n){c.h&&(c=l),i=0;break}if(l.t-n>e){i=a;break}a=v||e=v?x.points.length-1:0;for(f=x.points[S].point.length,d=0;d=T&&C=v)r[0]=b[0],r[1]=b[1],r[2]=b[2];else if(e<=y)r[0]=c.s[0],r[1]=c.s[1],r[2]=c.s[2];else{var j=Me(c.s),M=Me(b),N=(e-y)/(v-y);je(r,Ae(j,M,N))}else for(a=0;a=v?m=1:e1e-6?(f=Math.acos(p),m=Math.sin(f),h=Math.sin((1-n)*f)/m,g=Math.sin(n*f)/m):(h=1-n,g=n),r[0]=h*i+g*c,r[1]=h*a+g*l,r[2]=h*o+g*u,r[3]=h*s+g*d,r}function je(e,t){var n=t[0],r=t[1],i=t[2],a=t[3],o=Math.atan2(2*r*a-2*n*i,1-2*r*r-2*i*i),s=Math.asin(2*n*r+2*i*a),c=Math.atan2(2*n*a-2*r*i,1-2*n*n-2*i*i);e[0]=o/D,e[1]=s/D,e[2]=c/D}function Me(e){var t=e[0]*D,n=e[1]*D,r=e[2]*D,i=Math.cos(t/2),a=Math.cos(n/2),o=Math.cos(r/2),s=Math.sin(t/2),c=Math.sin(n/2),l=Math.sin(r/2),u=i*a*o-s*c*l;return[s*c*o+i*a*l,s*a*o+i*c*l,i*c*o-s*a*l,u]}function Ne(){var e=this.comp.renderedFrame-this.offsetTime,t=this.keyframes[0].t-this.offsetTime,n=this.keyframes[this.keyframes.length-1].t-this.offsetTime;if(!(e===this._caching.lastFrame||this._caching.lastFrame!==De&&(this._caching.lastFrame>=n&&e>=n||this._caching.lastFrame=e&&(this._caching._lastKeyframeIndex=-1,this._caching.lastIndex=0);var r=this.interpolateValue(e,this._caching);this.pv=r}return this._caching.lastFrame=e,this.pv}function Pe(e){var t;if(this.propType===`unidimensional`)t=e*this.mult,Oe(this.v-t)>1e-5&&(this.v=t,this._mdf=!0);else for(var n=0,r=this.v.length;n1e-5&&(this.v[n]=t,this._mdf=!0),n+=1}function Fe(){if(!(this.elem.globalData.frameId===this.frameId||!this.effectsSequence.length)){if(this.lock){this.setVValue(this.pv);return}this.lock=!0,this._mdf=this._isFirstFrame;var e,t=this.effectsSequence.length,n=this.kf?this.pv:this.data.k;for(e=0;e=this._maxLength&&this.doubleArrayLength(),n){case`v`:a=this.v;break;case`i`:a=this.i;break;case`o`:a=this.o;break;default:a=[];break}(!a[r]||a[r]&&!i)&&(a[r]=He.newElement()),a[r][0]=e,a[r][1]=t},Ue.prototype.setTripleAt=function(e,t,n,r,i,a,o,s){this.setXYAt(e,t,`v`,o,s),this.setXYAt(n,r,`o`,o,s),this.setXYAt(i,a,`i`,o,s)},Ue.prototype.reverse=function(){var e=new Ue;e.setPathData(this.c,this._length);var t=this.v,n=this.o,r=this.i,i=0;this.c&&(e.setTripleAt(t[0][0],t[0][1],r[0][0],r[0][1],n[0][0],n[0][1],0,!1),i=1);var a=this._length-1,o=this._length,s;for(s=i;s=p[p.length-1].t-this.offsetTime)i=p[p.length-1].s?p[p.length-1].s[0]:p[p.length-2].e[0],o=!0;else{for(var m=r,h=p.length-1,g=!0,_,v,y;g&&(_=p[m],v=p[m+1],!(v.t-this.offsetTime>e));)m=v.t-this.offsetTime)d=1;else if(e<_.t-this.offsetTime)d=0;else{var b;y.__fnct?b=y.__fnct:(b=xe.getBezierEasing(_.o.x,_.o.y,_.i.x,_.i.y).get,y.__fnct=b),d=b((e-(_.t-this.offsetTime))/(v.t-this.offsetTime-(_.t-this.offsetTime)))}a=v.s?v.s[0]:_.e[0]}i=_.s[0]}for(l=t._length,u=i.i[0].length,n.lastIndex=r,s=0;sr&&t>r)||(this._caching.lastIndex=i0||e>-1e-6&&e<0?r(e*t)/t:e}function P(){var e=this.props,t=N(e[0]),n=N(e[1]),r=N(e[4]),i=N(e[5]),a=N(e[12]),o=N(e[13]);return`matrix(`+t+`,`+n+`,`+r+`,`+i+`,`+a+`,`+o+`)`}return function(){this.reset=i,this.rotate=a,this.rotateX=o,this.rotateY=s,this.rotateZ=c,this.skew=u,this.skewFromAxis=d,this.shear=l,this.scale=f,this.setTransform=m,this.translate=h,this.transform=g,this.multiply=_,this.applyToPoint=S,this.applyToX=C,this.applyToY=w,this.applyToZ=T,this.applyToPointArray=A,this.applyToTriplePoints=k,this.applyToPointStringified=j,this.toCSS=M,this.to2dCSS=P,this.clone=b,this.cloneFromProps=x,this.equals=y,this.inversePoints=O,this.inversePoint=D,this.getInverseMatrix=E,this._t=this.transform,this.isIdentity=v,this._identity=!0,this._identityCalculated=!1,this.props=p(`float32`,16),this.reset()}}();function q(e){"@babel/helpers - typeof";return q=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},q(e)}var J={},qe=`__[STANDALONE]__`,Je=`__[ANIMATIONDATA]__`,Ye=``;function Xe(e){s(e)}function Ze(){qe===!0?H.searchAnimations(Je,qe,Ye):H.searchAnimations()}function Qe(e){re(e)}function $e(e){ue(e)}function et(e){return qe===!0&&(e.animationData=JSON.parse(Je)),H.loadAnimation(e)}function tt(e){if(typeof e==`string`)switch(e){case`high`:ce(200);break;default:case`medium`:ce(50);break;case`low`:ce(10);break}else!isNaN(e)&&e>1&&ce(e)}function nt(){return typeof navigator<`u`}function rt(e,t){e===`expressions`&&ae(t)}function it(e){switch(e){case`propertyFactory`:return W;case`shapePropertyFactory`:return G;case`matrix`:return K;default:return null}}J.play=H.play,J.pause=H.pause,J.setLocationHref=Xe,J.togglePause=H.togglePause,J.setSpeed=H.setSpeed,J.setDirection=H.setDirection,J.stop=H.stop,J.searchAnimations=Ze,J.registerAnimation=H.registerAnimation,J.loadAnimation=et,J.setSubframeRendering=Qe,J.resize=H.resize,J.goToAndStop=H.goToAndStop,J.destroy=H.destroy,J.setQuality=tt,J.inBrowser=nt,J.installPlugin=rt,J.freeze=H.freeze,J.unfreeze=H.unfreeze,J.setVolume=H.setVolume,J.mute=H.mute,J.unmute=H.unmute,J.getRegisteredAnimations=H.getRegisteredAnimations,J.useWebWorker=a,J.setIDPrefix=$e,J.__getFactory=it,J.version=`5.13.0`;function at(){document.readyState===`complete`&&(clearInterval(ut),Ze())}function ot(e){for(var t=st.split(`&`),n=0;n=1?a.push({s:e-1,e:t-1}):(a.push({s:e,e:1}),a.push({s:0,e:t-1}));var o=[],s,c=a.length,l;for(s=0;sr+n)){var u=l.s*i<=r?0:(l.s*i-r)/n,d=l.e*i>=r+n?1:(l.e*i-r)/n;o.push([u,d])}return o.length||o.push([0,0]),o},pt.prototype.releasePathsData=function(e){var t,n=e.length;for(t=0;t1?1+r:this.s.v<0?0+r:this.s.v+r,n=this.e.v>1?1+r:this.e.v<0?0+r:this.e.v+r,t>n){var i=t;t=n,n=i}t=Math.round(t*1e4)*1e-4,n=Math.round(n*1e4)*1e-4,this.sValue=t,this.eValue=n}else t=this.sValue,n=this.eValue;var a,o,s=this.shapes.length,c,l,u,d,f,p=0;if(n===t)for(o=0;o=0;--o)if(h=this.shapes[o],h.shape._mdf){for(g=h.localShapeCollection,g.releaseShapes(),this.m===2&&s>1?(b=this.calculateShapeEdges(t,n,h.totalShapeLength,y,p),y+=h.totalShapeLength):b=[[_,v]],l=b.length,c=0;c=1?m.push({s:h.totalShapeLength*(_-1),e:h.totalShapeLength*(v-1)}):(m.push({s:h.totalShapeLength*_,e:h.totalShapeLength}),m.push({s:0,e:h.totalShapeLength*(v-1)}));var x=this.addShapes(h,m[0]);if(m[0].s!==m[0].e){if(m.length>1)if(h.shape.paths.shapes[h.shape.paths._length-1].c){var S=x.pop();this.addPaths(x,g),x=this.addShapes(h,m[1],S)}else this.addPaths(x,g),x=this.addShapes(h,m[1]);this.addPaths(x,g)}}h.shape.paths=g}}else if(this._mdf)for(o=0;ot.e){n.c=!1;break}else t.s<=l&&t.e>=l+u.addedLength?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[s],i[a].v[s],n,d,g),g=!1):(p=Ee.getNewSegment(i[a].v[s-1],i[a].v[s],i[a].o[s-1],i[a].i[s],(t.s-l)/u.addedLength,(t.e-l)/u.addedLength,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1),l+=u.addedLength,d+=1;if(i[a].c&&f.length){if(u=f[s-1],l<=t.e){var _=f[s-1].addedLength;t.s<=l&&t.e>=l+_?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[0],i[a].v[0],n,d,g),g=!1):(p=Ee.getNewSegment(i[a].v[s-1],i[a].v[0],i[a].o[s-1],i[a].i[0],(t.s-l)/_,(t.e-l)/_,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1)}else n.c=!1;l+=u.addedLength,d+=1}if(n._length&&(n.setXYAt(n.v[h][0],n.v[h][1],`i`,h),n.setXYAt(n.v[n._length-1][0],n.v[n._length-1][1],`o`,n._length-1)),l>t.e)break;a=this.p.keyframes[this.p.keyframes.length-1].t?(r=this.p.getValueAtTime(this.p.keyframes[this.p.keyframes.length-1].t/n,0),i=this.p.getValueAtTime((this.p.keyframes[this.p.keyframes.length-1].t-.05)/n,0)):(r=this.p.pv,i=this.p.getValueAtTime((this.p._caching.lastFrame+this.p.offsetTime-.01)/n,this.p.offsetTime));else if(this.px&&this.px.keyframes&&this.py.keyframes&&this.px.getValueAtTime&&this.py.getValueAtTime){r=[],i=[];var a=this.px,o=this.py;a._caching.lastFrame+a.offsetTime<=a.keyframes[0].t?(r[0]=a.getValueAtTime((a.keyframes[0].t+.01)/n,0),r[1]=o.getValueAtTime((o.keyframes[0].t+.01)/n,0),i[0]=a.getValueAtTime(a.keyframes[0].t/n,0),i[1]=o.getValueAtTime(o.keyframes[0].t/n,0)):a._caching.lastFrame+a.offsetTime>=a.keyframes[a.keyframes.length-1].t?(r[0]=a.getValueAtTime(a.keyframes[a.keyframes.length-1].t/n,0),r[1]=o.getValueAtTime(o.keyframes[o.keyframes.length-1].t/n,0),i[0]=a.getValueAtTime((a.keyframes[a.keyframes.length-1].t-.01)/n,0),i[1]=o.getValueAtTime((o.keyframes[o.keyframes.length-1].t-.01)/n,0)):(r=[a.pv,o.pv],i[0]=a.getValueAtTime((a._caching.lastFrame+a.offsetTime-.01)/n,a.offsetTime),i[1]=o.getValueAtTime((o._caching.lastFrame+o.offsetTime-.01)/n,o.offsetTime))}else i=e,r=i;this.v.rotate(-Math.atan2(r[1]-i[1],r[0]-i[0]))}this.data.p&&this.data.p.s?this.data.p.z?this.v.translate(this.px.v,this.py.v,-this.pz.v):this.v.translate(this.px.v,this.py.v,0):this.v.translate(this.p.v[0],this.p.v[1],-this.p.v[2])}this.frameId=this.elem.globalData.frameId}}function r(){if(this.appliedTransformations=0,this.pre.reset(),!this.a.effectsSequence.length)this.pre.translate(-this.a.v[0],-this.a.v[1],this.a.v[2]),this.appliedTransformations=1;else return;if(!this.s.effectsSequence.length)this.pre.scale(this.s.v[0],this.s.v[1],this.s.v[2]),this.appliedTransformations=2;else return;if(this.sk)if(!this.sk.effectsSequence.length&&!this.sa.effectsSequence.length)this.pre.skewFromAxis(-this.sk.v,this.sa.v),this.appliedTransformations=3;else return;this.r?this.r.effectsSequence.length||(this.pre.rotate(-this.r.v),this.appliedTransformations=4):!this.rz.effectsSequence.length&&!this.ry.effectsSequence.length&&!this.rx.effectsSequence.length&&!this.or.effectsSequence.length&&(this.pre.rotateZ(-this.rz.v).rotateY(this.ry.v).rotateX(this.rx.v).rotateZ(-this.or.v[2]).rotateY(this.or.v[1]).rotateX(this.or.v[0]),this.appliedTransformations=4)}function i(){}function a(e){this._addDynamicProperty(e),this.elem.addDynamicProperty(e),this._isDirty=!0}function o(e,t,n){if(this.elem=e,this.frameId=-1,this.propType=`transform`,this.data=t,this.v=new K,this.pre=new K,this.appliedTransformations=0,this.initDynamicPropertyContainer(n||e),t.p&&t.p.s?(this.px=W.getProp(e,t.p.x,0,0,this),this.py=W.getProp(e,t.p.y,0,0,this),t.p.z&&(this.pz=W.getProp(e,t.p.z,0,0,this))):this.p=W.getProp(e,t.p||{k:[0,0,0]},1,0,this),t.rx){if(this.rx=W.getProp(e,t.rx,0,D,this),this.ry=W.getProp(e,t.ry,0,D,this),this.rz=W.getProp(e,t.rz,0,D,this),t.or.k[0].ti){var r,i=t.or.k.length;for(r=0;r0;)--n,this._elements.unshift(t[n]);this.dynamicProperties.length?this.k=!0:this.getValue(!0)},gt.prototype.resetElements=function(e){var t,n=e.length;for(t=0;t0?Math.floor(f):Math.ceil(f),h=this.pMatrix.props,g=this.rMatrix.props,_=this.sMatrix.props;this.pMatrix.reset(),this.rMatrix.reset(),this.sMatrix.reset(),this.tMatrix.reset(),this.matrix.reset();var v=0;if(f>0){for(;vm;)this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,1,!0),--v;p&&(this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,-p,!0),v-=p)}r=this.data.m===1?0:this._currentCopies-1,i=this.data.m===1?1:-1,a=this._currentCopies;for(var y,b;a;){if(t=this.elemsData[r].it,n=t[t.length-1].transform.mProps.v.props,b=n.length,t[t.length-1].transform.mProps._mdf=!0,t[t.length-1].transform.op._mdf=!0,t[t.length-1].transform.op.v=this._currentCopies===1?this.so.v:this.so.v+(this.eo.v-this.so.v)*(r/(this._currentCopies-1)),v!==0){for((r!==0&&i===1||r!==this._currentCopies-1&&i===-1)&&this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,1,!1),this.matrix.transform(g[0],g[1],g[2],g[3],g[4],g[5],g[6],g[7],g[8],g[9],g[10],g[11],g[12],g[13],g[14],g[15]),this.matrix.transform(_[0],_[1],_[2],_[3],_[4],_[5],_[6],_[7],_[8],_[9],_[10],_[11],_[12],_[13],_[14],_[15]),this.matrix.transform(h[0],h[1],h[2],h[3],h[4],h[5],h[6],h[7],h[8],h[9],h[10],h[11],h[12],h[13],h[14],h[15]),y=0;y0&&r<1?[t]:[]:[t-r,t+r].filter(function(e){return e>0&&e<1})},Tt.prototype.split=function(e){if(e<=0)return[wt(this.points[0]),this];if(e>=1)return[this,wt(this.points[this.points.length-1])];var t=xt(this.points[0],this.points[1],e),n=xt(this.points[1],this.points[2],e),r=xt(this.points[2],this.points[3],e),i=xt(t,n,e),a=xt(n,r,e),o=xt(i,a,e);return[new Tt(this.points[0],t,i,o,!0),new Tt(o,a,r,this.points[3],!0)]};function Et(e,t){var n=e.points[0][t],r=e.points[e.points.length-1][t];if(n>r){var i=r;r=n,n=i}for(var a=St(3*e.a[t],2*e.b[t],e.c[t]),o=0;o0&&a[o]<1){var s=e.point(a[o])[t];sr&&(r=s)}return{min:n,max:r}}Tt.prototype.bounds=function(){return{x:Et(this,0),y:Et(this,1)}},Tt.prototype.boundingBox=function(){var e=this.bounds();return{left:e.x.min,right:e.x.max,top:e.y.min,bottom:e.y.max,width:e.x.max-e.x.min,height:e.y.max-e.y.min,cx:(e.x.max+e.x.min)/2,cy:(e.y.max+e.y.min)/2}};function Dt(e,t,n){var r=e.boundingBox();return{cx:r.cx,cy:r.cy,width:r.width,height:r.height,bez:e,t:(t+n)/2,t1:t,t2:n}}function Ot(e){var t=e.bez.split(.5);return[Dt(t[0],e.t1,e.t),Dt(t[1],e.t,e.t2)]}function kt(e,t){return Math.abs(e.cx-t.cx)*2=a||e.width<=r&&e.height<=r&&t.width<=r&&t.height<=r){i.push([e.t,t.t]);return}var o=Ot(e),s=Ot(t);At(o[0],s[0],n+1,r,i,a),At(o[0],s[1],n+1,r,i,a),At(o[1],s[0],n+1,r,i,a),At(o[1],s[1],n+1,r,i,a)}}Tt.prototype.intersections=function(e,t,n){t===void 0&&(t=2),n===void 0&&(n=7);var r=[];return At(Dt(this,0,1),Dt(e,0,1),0,t,r,n),r},Tt.shapeSegment=function(e,t){var n=(t+1)%e.length();return new Tt(e.v[t],e.o[t],e.i[n],e.v[n],!0)},Tt.shapeSegmentInverted=function(e,t){var n=(t+1)%e.length();return new Tt(e.v[n],e.i[n],e.o[t],e.v[t],!0)};function jt(e,t){return[e[1]*t[2]-e[2]*t[1],e[2]*t[0]-e[0]*t[2],e[0]*t[1]-e[1]*t[0]]}function Mt(e,t,n,r){var i=[e[0],e[1],1],a=[t[0],t[1],1],o=[n[0],n[1],1],s=[r[0],r[1],1],c=jt(jt(i,a),jt(o,s));return yt(c[2])?null:[c[0]/c[2],c[1]/c[2]]}function Y(e,t,n){return[e[0]+Math.cos(t)*n,e[1]-Math.sin(t)*n]}function Nt(e,t){return Math.hypot(e[0]-t[0],e[1]-t[1])}function Pt(e,t){return vt(e[0],t[0])&&vt(e[1],t[1])}function Ft(){}u([ft],Ft),Ft.prototype.initModifierProperties=function(e,t){this.getValue=this.processKeys,this.amplitude=W.getProp(e,t.s,0,null,this),this.frequency=W.getProp(e,t.r,0,null,this),this.pointsType=W.getProp(e,t.pt,0,null,this),this._isAnimated=this.amplitude.effectsSequence.length!==0||this.frequency.effectsSequence.length!==0||this.pointsType.effectsSequence.length!==0};function It(e,t,n,r,i,a,o){var s=n-Math.PI/2,c=n+Math.PI/2,l=t[0]+Math.cos(n)*r*i,u=t[1]-Math.sin(n)*r*i;e.setTripleAt(l,u,l+Math.cos(s)*a,u-Math.sin(s)*a,l+Math.cos(c)*o,u-Math.sin(c)*o,e.length())}function Lt(e,t){var n=[t[0]-e[0],t[1]-e[1]],r=-Math.PI*.5;return[Math.cos(r)*n[0]-Math.sin(r)*n[1],Math.sin(r)*n[0]+Math.cos(r)*n[1]]}function Rt(e,t){var n=t===0?e.length()-1:t-1,r=(t+1)%e.length(),i=e.v[n],a=e.v[r],o=Lt(i,a);return Math.atan2(0,1)-Math.atan2(o[1],o[0])}function zt(e,t,n,r,i,a,o){var s=Rt(t,n),c=t.v[n%t._length],l=t.v[n===0?t._length-1:n-1],u=t.v[(n+1)%t._length],d=a===2?Math.sqrt((c[0]-l[0])**2+(c[1]-l[1])**2):0,f=a===2?Math.sqrt((c[0]-u[0])**2+(c[1]-u[1])**2):0;It(e,t.v[n%t._length],s,o,r,f/((i+1)*2),d/((i+1)*2),a)}function Bt(e,t,n,r,i,a){for(var o=0;o1&&t.length>1&&(i=Wt(e[0],t[t.length-1]),i)?[[e[0].split(i[0])[0]],[t[t.length-1].split(i[1])[1]]]:[n,r]}function Kt(e){for(var t,n=1;n1&&(t=Gt(e[e.length-1],e[0]),e[e.length-1]=t[0],e[0]=t[1]),e}function qt(e,t){var n=e.inflectionPoints(),r,i,a,o;if(n.length===0)return[Ht(e,t)];if(n.length===1||vt(n[1],1))return a=e.split(n[0]),r=a[0],i=a[1],[Ht(r,t),Ht(i,t)];a=e.split(n[0]),r=a[0];var s=(n[1]-n[0])/(1-n[0]);return a=a[1].split(s),o=a[0],i=a[1],[Ht(r,t),Ht(o,t),Ht(i,t)]}function Jt(){}u([ft],Jt),Jt.prototype.initModifierProperties=function(e,t){this.getValue=this.processKeys,this.amount=W.getProp(e,t.a,0,null,this),this.miterLimit=W.getProp(e,t.ml,0,null,this),this.lineJoin=t.lj,this._isAnimated=this.amount.effectsSequence.length!==0},Jt.prototype.processPath=function(e,t,n,r){var i=We.newElement();i.c=e.c;var a=e.length();e.c||--a;var o,s,c,l=[];for(o=0;o=0;--o)c=Tt.shapeSegmentInverted(e,o),l.push(qt(c,t));l=Kt(l);var u=null,d=null;for(o=0;o0&&(o=!1),o){var u=l(`style`);u.setAttribute(`f-forigin`,n[r].fOrigin),u.setAttribute(`f-origin`,n[r].origin),u.setAttribute(`f-family`,n[r].fFamily),u.type=`text/css`,u.innerText=`@font-face {font-family: `+n[r].fFamily+`; font-style: normal; src: url('`+n[r].fPath+`');}`,t.appendChild(u)}}else if(n[r].fOrigin===`g`||n[r].origin===1){for(s=document.querySelectorAll(`link[f-forigin="g"], link[f-origin="1"]`),c=0;c=55296&&n<=56319){var r=e.charCodeAt(1);r>=56320&&r<=57343&&(t=(n-55296)*1024+r-56320+65536)}return t}function S(e,t){var n=e.toString(16)+t.toString(16);return d.indexOf(n)!==-1}function C(e){return e===s}function w(e){return e===o}function T(e){var t=x(e);return t>=c&&t<=u}function E(e){return T(e.substr(0,2))&&T(e.substr(2,2))}function D(e){return t.indexOf(e)!==-1}function O(e,t){var o=x(e.substr(t,2));if(o!==n)return!1;var s=0;for(t+=2;s<5;){if(o=x(e.substr(t,2)),oa)return!1;s+=1,t+=2}return x(e.substr(t,2))===r}function k(){this.isLoaded=!0}var A=function(){this.fonts=[],this.chars=null,this.typekitLoaded=0,this.isLoaded=!1,this._warned=!1,this.initTime=Date.now(),this.setIsLoadedBinded=this.setIsLoaded.bind(this),this.checkLoadedFontsBinded=this.checkLoadedFonts.bind(this)};return A.isModifier=S,A.isZeroWidthJoiner=C,A.isFlagEmoji=E,A.isRegionalCode=T,A.isCombinedCharacter=D,A.isRegionalFlag=O,A.isVariationSelector=w,A.BLACK_FLAG_CODE_POINT=n,A.prototype={addChars:_,addFonts:g,getCharData:v,getFontByName:b,measureText:y,checkLoadedFonts:m,setIsLoaded:k},A}();function Zt(e){this.animationData=e}Zt.prototype.getProp=function(e){return this.animationData.slots&&this.animationData.slots[e.sid]?Object.assign(e,this.animationData.slots[e.sid].p):e};function Qt(e){return new Zt(e)}function $t(){}$t.prototype={initRenderable:function(){this.isInRange=!1,this.hidden=!1,this.isTransparent=!1,this.renderableComponents=[]},addRenderableComponent:function(e){this.renderableComponents.indexOf(e)===-1&&this.renderableComponents.push(e)},removeRenderableComponent:function(e){this.renderableComponents.indexOf(e)!==-1&&this.renderableComponents.splice(this.renderableComponents.indexOf(e),1)},prepareRenderableFrame:function(e){this.checkLayerLimits(e)},checkTransparency:function(){this.finalTransform.mProp.o.v<=0?!this.isTransparent&&this.globalData.renderConfig.hideOnTransparent&&(this.isTransparent=!0,this.hide()):this.isTransparent&&(this.isTransparent=!1,this.show())},checkLayerLimits:function(e){this.data.ip-this.data.st<=e&&this.data.op-this.data.st>e?this.isInRange!==!0&&(this.globalData._mdf=!0,this._mdf=!0,this.isInRange=!0,this.show()):this.isInRange!==!1&&(this.globalData._mdf=!0,this.isInRange=!1,this.hide())},renderRenderable:function(){var e,t=this.renderableComponents.length;for(e=0;e.1)&&this.audio.seek(this._currentTime/this.globalData.frameRate):(this.audio.play(),this.audio.seek(this._currentTime/this.globalData.frameRate),this._isPlaying=!0))},hn.prototype.show=function(){},hn.prototype.hide=function(){this.audio.pause(),this._isPlaying=!1},hn.prototype.pause=function(){this.audio.pause(),this._isPlaying=!1,this._canPlay=!1},hn.prototype.resume=function(){this._canPlay=!0},hn.prototype.setRate=function(e){this.audio.rate(e)},hn.prototype.volume=function(e){this._volumeMultiplier=e,this._previousVolume=e*this._volume,this.audio.volume(this._previousVolume)},hn.prototype.getBaseElement=function(){return null},hn.prototype.destroy=function(){},hn.prototype.sourceRectAtTime=function(){},hn.prototype.initExpressions=function(){};function gn(){}gn.prototype.checkLayers=function(e){var t,n=this.layers.length,r;for(this.completeLayers=!0,t=n-1;t>=0;--t)this.elements[t]||(r=this.layers[t],r.ip-r.st<=e-this.layers[t].st&&r.op-r.st>e-this.layers[t].st&&this.buildItem(t)),this.completeLayers=this.elements[t]?this.completeLayers:!1;this.checkPendingElements()},gn.prototype.createItem=function(e){switch(e.ty){case 2:return this.createImage(e);case 0:return this.createComp(e);case 1:return this.createSolid(e);case 3:return this.createNull(e);case 4:return this.createShape(e);case 5:return this.createText(e);case 6:return this.createAudio(e);case 13:return this.createCamera(e);case 15:return this.createFootage(e);default:return this.createNull(e)}},gn.prototype.createCamera=function(){throw Error(`You're using a 3d camera. Try the html renderer.`)},gn.prototype.createAudio=function(e){return new hn(e,this.globalData,this)},gn.prototype.createFootage=function(e){return new mn(e,this.globalData,this)},gn.prototype.buildAllItems=function(){var e,t=this.layers.length;for(e=0;e0&&(this.maskElement.setAttribute(`id`,p),this.element.maskedElement.setAttribute(b,`url(`+c()+`#`+p+`)`),r.appendChild(this.maskElement)),this.viewData.length&&this.element.addRenderableComponent(this)}yn.prototype.getMaskProperty=function(e){return this.viewData[e].prop},yn.prototype.renderFrame=function(e){var t=this.element.finalTransform.mat,n,r=this.masksProperties.length;for(n=0;n1&&(r+=` C`+t.o[i-1][0]+`,`+t.o[i-1][1]+` `+t.i[0][0]+`,`+t.i[0][1]+` `+t.v[0][0]+`,`+t.v[0][1]),n.lastPath!==r){var o=``;n.elem&&(t.c&&(o=e.inv?this.solidPath+r:r),n.elem.setAttribute(`d`,o)),n.lastPath=r}},yn.prototype.destroy=function(){this.element=null,this.globalData=null,this.maskElement=null,this.data=null,this.masksProperties=null};var bn=function(){var e={};e.createFilter=t,e.createAlphaToLuminanceFilter=n;function t(e,t){var n=z(`filter`);return n.setAttribute(`id`,e),t!==!0&&(n.setAttribute(`filterUnits`,`objectBoundingBox`),n.setAttribute(`x`,`0%`),n.setAttribute(`y`,`0%`),n.setAttribute(`width`,`100%`),n.setAttribute(`height`,`100%`)),n}function n(){var e=z(`feColorMatrix`);return e.setAttribute(`type`,`matrix`),e.setAttribute(`color-interpolation-filters`,`sRGB`),e.setAttribute(`values`,`0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 1`),e}return e}(),xn=function(){var e={maskType:!0,svgLumaHidden:!0,offscreenCanvas:typeof OffscreenCanvas<`u`};return(/MSIE 10/i.test(navigator.userAgent)||/MSIE 9/i.test(navigator.userAgent)||/rv:11.0/i.test(navigator.userAgent)||/Edge\/\d./i.test(navigator.userAgent))&&(e.maskType=!1),/firefox/i.test(navigator.userAgent)&&(e.svgLumaHidden=!1),e}(),Sn={},Cn=`filter_result_`;function wn(e){var t,n=`SourceGraphic`,r=e.data.ef?e.data.ef.length:0,i=I(),a=bn.createFilter(i,!0),o=0;this.filters=[];var s;for(t=0;t=0&&(n=this.shapeModifiers[e].processShapes(this._isFirstFrame),!n);--e);}},searchProcessedElement:function(e){for(var t=this.processedElements,n=0,r=t.length;n.01)return!1;n+=1}return!0},zn.prototype.checkCollapsable=function(){if(this.o.length/2!=this.c.length/4)return!1;if(this.data.k.k[0].s)for(var e=0,t=this.data.k.k.length;e0;)c=r.transformers[g].mProps._mdf||c,--h,--g;if(c)for(h=f-r.styles[u].lvl,g=r.transformers.length-1;h>0;)m.multiply(r.transformers[g].mProps.v),--h,--g}else m=e;if(p=r.sh.paths,o=p._length,c){for(s=``,a=0;a=1?v=.99:v<=-1&&(v=-.99);var y=g*v,b=Math.cos(_+t.a.v)*y+a[0],x=Math.sin(_+t.a.v)*y+a[1];r.setAttribute(`fx`,b),r.setAttribute(`fy`,x),i&&!t.g._collapsable&&(t.of.setAttribute(`fx`,b),t.of.setAttribute(`fy`,x))}}}function u(e,t,n){var r=t.style,i=t.d;i&&(i._mdf||n)&&i.dashStr&&(r.pElem.setAttribute(`stroke-dasharray`,i.dashStr),r.pElem.setAttribute(`stroke-dashoffset`,i.dashoffset[0])),t.c&&(t.c._mdf||n)&&r.pElem.setAttribute(`stroke`,`rgb(`+C(t.c.v[0])+`,`+C(t.c.v[1])+`,`+C(t.c.v[2])+`)`),(t.o._mdf||n)&&r.pElem.setAttribute(`stroke-opacity`,t.o.v),(t.w._mdf||n)&&(r.pElem.setAttribute(`stroke-width`,t.w.v),r.msElem&&r.msElem.setAttribute(`stroke-width`,t.w.v))}return n}();function X(e,t,n){this.shapes=[],this.shapesData=e.shapes,this.stylesList=[],this.shapeModifiers=[],this.itemsData=[],this.processedElements=[],this.animatedContents=[],this.initElement(e,t,n),this.prevViewData=[]}u([fn,vn,Tn,An,En,pn,Dn],X),X.prototype.initSecondaryElement=function(){},X.prototype.identityMatrix=new K,X.prototype.buildExpressionInterface=function(){},X.prototype.createContent=function(){this.searchShapes(this.shapesData,this.itemsData,this.prevViewData,this.layerElement,0,[],!0),this.filterUniqueShapes()},X.prototype.filterUniqueShapes=function(){var e,t=this.shapes.length,n,r,i=this.stylesList.length,a,o=[],s=!1;for(r=0;r1&&s&&this.setShapesAsAnimated(o)}},X.prototype.setShapesAsAnimated=function(e){var t,n=e.length;for(t=0;t=0;--c){if(g=this.searchProcessedElement(e[c]),g?t[c]=n[g-1]:e[c]._render=o,e[c].ty===`fl`||e[c].ty===`st`||e[c].ty===`gf`||e[c].ty===`gs`||e[c].ty===`no`)g?t[c].style.closed=e[c].hd:t[c]=this.createStyleElement(e[c],i),e[c]._render&&t[c].style.pElem.parentNode!==r&&r.appendChild(t[c].style.pElem),f.push(t[c].style);else if(e[c].ty===`gr`){if(!g)t[c]=this.createGroupElement(e[c]);else for(d=t[c].it.length,u=0;u1,this.kf&&this.addEffect(this.getKeyframeValue.bind(this)),this.kf},qn.prototype.addEffect=function(e){this.effectsSequence.push(e),this.elem.addDynamicProperty(this)},qn.prototype.getValue=function(e){if(!((this.elem.globalData.frameId===this.frameId||!this.effectsSequence.length)&&!e)){this.currentData.t=this.data.d.k[this.keysIndex].s.t;var t=this.currentData,n=this.keysIndex;if(this.lock){this.setCurrentData(this.currentData);return}this.lock=!0,this._mdf=!1;var r,i=this.effectsSequence.length,a=e||this.data.d.k[this.keysIndex].s;for(r=0;rt);)n+=1;return this.keysIndex!==n&&(this.keysIndex=n),this.data.d.k[this.keysIndex].s},qn.prototype.buildFinalText=function(e){for(var t=[],n=0,r=e.length,i,a,o=!1,s=!1,c=``;n=55296&&i<=56319?Xt.isRegionalFlag(e,n)?c=e.substr(n,14):(a=e.charCodeAt(n+1),a>=56320&&a<=57343&&(Xt.isModifier(i,a)?(c=e.substr(n,2),o=!0):c=Xt.isFlagEmoji(e.substr(n,4))?e.substr(n,4):e.substr(n,2))):i>56319?(a=e.charCodeAt(n+1),Xt.isVariationSelector(i)&&(o=!0)):Xt.isZeroWidthJoiner(i)&&(o=!0,s=!0),o?(t[t.length-1]+=c,o=!1):t.push(c),n+=c.length;return t},qn.prototype.completeTextData=function(e){e.__complete=!0;var t=this.elem.globalData.fontManager,n=this.data,r=[],i,a,o,s=0,c,l=n.m.g,u=0,d=0,f=0,p=[],m=0,h=0,g,_,v=t.getFontByName(e.f),y,b=0,x=Yt(v);e.fWeight=x.weight,e.fStyle=x.style,e.finalSize=e.s,e.finalText=this.buildFinalText(e.t),a=e.finalText.length,e.finalLineHeight=e.lh;var S=e.tr/1e3*e.finalSize,C;if(e.sz)for(var w=!0,T=e.sz[0],E=e.sz[1],D,O;w;){O=this.buildFinalText(e.t),D=0,m=0,a=O.length,S=e.tr/1e3*e.finalSize;var k=-1;for(i=0;iT&&O[i]!==` `?(k===-1?a+=1:i=k,D+=e.finalLineHeight||e.finalSize*1.2,O.splice(i,+(k===i),`\r`),k=-1,m=0):(m+=b,m+=S);D+=v.ascent*e.finalSize/100,this.canResize&&e.finalSize>this.minimumFontSize&&Eh?m:h,m=-2*S,c=``,o=!0,f+=1):c=j,t.chars?(y=t.getCharData(j,v.fStyle,t.getFontByName(e.f).fFamily),b=o?0:y.w*e.finalSize/100):b=t.measureText(c,e.f,e.finalSize),j===` `?A+=b+S:(m+=b+S+A,A=0),r.push({l:b,an:b,add:u,n:o,anIndexes:[],val:c,line:f,animatorJustifyOffset:0}),l==2){if(u+=b,c===``||c===` `||i===a-1){for((c===``||c===` `)&&(u-=b);d<=i;)r[d].an=u,r[d].ind=s,r[d].extra=b,d+=1;s+=1,u=0}}else if(l==3){if(u+=b,c===``||i===a-1){for(c===``&&(u-=b);d<=i;)r[d].an=u,r[d].ind=s,r[d].extra=b,d+=1;u=0,s+=1}}else r[s].ind=s,r[s].extra=0,s+=1;if(e.l=r,h=m>h?m:h,p.push(m),e.sz)e.boxWidth=e.sz[0],e.justifyOffset=0;else switch(e.boxWidth=h,e.j){case 1:e.justifyOffset=-e.boxWidth;break;case 2:e.justifyOffset=-e.boxWidth/2;break;default:e.justifyOffset=0}e.lineWidths=p;var M=n.a,N,P;_=M.length;var F,I,L=[];for(g=0;g<_;g+=1){for(N=M[g],N.a.sc&&(e.strokeColorAnim=!0),N.a.sw&&(e.strokeWidthAnim=!0),(N.a.fc||N.a.fh||N.a.fs||N.a.fb)&&(e.fillColorAnim=!0),I=0,F=N.s.b,i=0;i0?i=this.ne.v/100:a=-this.ne.v/100,this.xe.v>0?o=1-this.xe.v/100:s=1+this.xe.v/100;var c=xe.getBezierEasing(i,a,o,s).get,l=0,u=this.finalS,d=this.finalE,f=this.data.sh;if(f===2)l=d===u?+(r>=d):e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l=c(l);else if(f===3)l=d===u?r>=d?0:1:1-e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l=c(l);else if(f===4)d===u?l=0:(l=e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l<.5?l*=2:l=1-2*(l-.5)),l=c(l);else if(f===5){if(d===u)l=0;else{var p=d-u;r=t(e(0,r+.5-u),d-u);var m=-p/2+r,h=p/2;l=Math.sqrt(1-m*m/(h*h))}l=c(l)}else f===6?(d===u?l=0:(r=t(e(0,r+.5-u),d-u),l=(1+Math.cos(Math.PI+Math.PI*2*r/(d-u)))/2),l=c(l)):(r>=n(u)&&(l=r-u<0?e(0,t(t(d,1)-(u-r),1)):e(0,t(d-r,1))),l=c(l));if(this.sm.v!==100){var g=this.sm.v*.01;g===0&&(g=1e-8);var _=.5-g*.5;l<_?l=0:(l=(l-_)/g,l>1&&(l=1))}return l*this.a.v},getValue:function(e){this.iterateDynamicProperties(),this._mdf=e||this._mdf,this._currentTextLength=this.elem.textProperty.currentData.l.length||0,e&&this.data.r===2&&(this.e.v=this._currentTextLength);var t=this.data.r===2?1:100/this.data.totalChars,n=this.o.v/t,r=this.s.v/t+n,i=this.e.v/t+n;if(r>i){var a=r;r=i,i=a}this.finalS=r,this.finalE=i}},u([Ve],r);function i(e,t,n){return new r(e,t,n)}return{getTextSelectorProp:i}}();function Yn(e,t,n){var r={propType:!1},i=W.getProp,a=t.a;this.a={r:a.r?i(e,a.r,0,D,n):r,rx:a.rx?i(e,a.rx,0,D,n):r,ry:a.ry?i(e,a.ry,0,D,n):r,sk:a.sk?i(e,a.sk,0,D,n):r,sa:a.sa?i(e,a.sa,0,D,n):r,s:a.s?i(e,a.s,1,.01,n):r,a:a.a?i(e,a.a,1,0,n):r,o:a.o?i(e,a.o,0,.01,n):r,p:a.p?i(e,a.p,1,0,n):r,sw:a.sw?i(e,a.sw,0,0,n):r,sc:a.sc?i(e,a.sc,1,0,n):r,fc:a.fc?i(e,a.fc,1,0,n):r,fh:a.fh?i(e,a.fh,0,0,n):r,fs:a.fs?i(e,a.fs,0,.01,n):r,fb:a.fb?i(e,a.fb,0,.01,n):r,t:a.t?i(e,a.t,0,0,n):r},this.s=Jn.getTextSelectorProp(e,t.s,n),this.s.t=t.s.t}function Xn(e,t,n){this._isFirstFrame=!0,this._hasMaskedPath=!1,this._frameId=-1,this._textData=e,this._renderType=t,this._elem=n,this._animatorsData=m(this._textData.a.length),this._pathData={},this._moreOptions={alignment:{}},this.renderedLetters=[],this.lettersChangedFlag=!1,this.initDynamicPropertyContainer(n)}Xn.prototype.searchProperties=function(){var e,t=this._textData.a.length,n,r=W.getProp;for(e=0;e=m+Se||!x?(T=(m+Se-g)/h.partialLength,ie=b.point[0]+(h.point[0]-b.point[0])*T,ae=b.point[1]+(h.point[1]-b.point[1])*T,a.translate(-n[0]*f[u].an*.005,-(n[1]*A)*.01),_=!1):x&&(g+=h.partialLength,v+=1,v>=x.length&&(v=0,y+=1,S[y]?x=S[y].points:D.v.c?(v=0,y=0,x=S[y].points):(g-=h.partialLength,x=null)),x&&(b=h,h=x[v],C=h.partialLength));re=f[u].an/2-f[u].add,a.translate(-re,0,0)}else re=f[u].an/2-f[u].add,a.translate(-re,0,0),a.translate(-n[0]*f[u].an*.005,-n[1]*A*.01,0);for(P=0;Pe?this.textSpans[e].span:z(s?`g`:`text`),b<=e){if(c.setAttribute(`stroke-linecap`,`butt`),c.setAttribute(`stroke-linejoin`,`round`),c.setAttribute(`stroke-miterlimit`,`4`),this.textSpans[e].span=c,s){var S=z(`g`);c.appendChild(S),this.textSpans[e].childSpan=S}this.textSpans[e].span=c,this.layerElement.appendChild(c)}c.style.display=`inherit`}if(l.reset(),d&&(o[e].n&&(f=-g,p+=n.yOffset,p+=+!!h,h=!1),this.applyTextPropertiesToMatrix(n,l,o[e].line,f,p),f+=o[e].l||0,f+=g),s){x=this.globalData.fontManager.getCharData(n.finalText[e],r.fStyle,this.globalData.fontManager.getFontByName(n.f).fFamily);var C;if(x.t===1)C=new ir(x.data,this.globalData,this);else{var w=Qn;x.data&&x.data.shapes&&(w=this.buildShapeData(x.data,n.finalSize)),C=new X(w,this.globalData,this)}if(this.textSpans[e].glyph){var T=this.textSpans[e].glyph;this.textSpans[e].childSpan.removeChild(T.layerElement),T.destroy()}this.textSpans[e].glyph=C,C._debug=!0,C.prepareFrame(0),C.renderFrame(),this.textSpans[e].childSpan.appendChild(C.layerElement),x.t===1&&this.textSpans[e].childSpan.setAttribute(`transform`,`scale(`+n.finalSize/100+`,`+n.finalSize/100+`)`)}else d&&c.setAttribute(`transform`,`translate(`+l.props[12]+`,`+l.props[13]+`)`),c.textContent=o[e].val,c.setAttributeNS(`http://www.w3.org/XML/1998/namespace`,`xml:space`,`preserve`)}d&&c&&c.setAttribute(`d`,u)}for(;e=0;--t)(this.completeLayers||this.elements[t])&&this.elements[t].prepareFrame(e-this.layers[t].st);if(this.globalData._mdf)for(t=0;t=0;--n)(this.completeLayers||this.elements[n])&&(this.elements[n].prepareFrame(this.renderedFrame-this.layers[n].st),this.elements[n]._mdf&&(this._mdf=!0))}},rr.prototype.renderInnerContent=function(){var e,t=this.layers.length;for(e=0;e=0;--n)e.finalTransform.multiply(e.transforms[n].transform.mProps.v);e._mdf=i},processSequences:function(e){var t,n=this.sequenceList.length;for(t=0;t=1){this.buffers=[];var e=this.globalData.canvasContext,t=lr.createCanvas(e.canvas.width,e.canvas.height);this.buffers.push(t);var n=lr.createCanvas(e.canvas.width,e.canvas.height);this.buffers.push(n),this.data.tt>=3&&!document._isProxy&&lr.loadLumaCanvas()}this.canvasContext=this.globalData.canvasContext,this.transformCanvas=this.globalData.transformCanvas,this.renderableEffectsManager=new dr(this),this.searchEffectTransforms()},createContent:function(){},setBlendMode:function(){var e=this.globalData;if(e.blendMode!==this.data.bm){e.blendMode=this.data.bm;var t=en(this.data.bm);e.canvasContext.globalCompositeOperation=t}},createRenderableComponents:function(){this.maskManager=new fr(this.data,this),this.transformEffects=this.renderableEffectsManager.getEffects(_n.TRANSFORM_EFFECT)},hideElement:function(){!this.hidden&&(!this.isInRange||this.isTransparent)&&(this.hidden=!0)},showElement:function(){this.isInRange&&!this.isTransparent&&(this.hidden=!1,this._isFirstFrame=!0,this.maskManager._isFirstFrame=!0)},clearCanvas:function(e){e.clearRect(this.transformCanvas.tx,this.transformCanvas.ty,this.transformCanvas.w*this.transformCanvas.sx,this.transformCanvas.h*this.transformCanvas.sy)},prepareLayer:function(){if(this.data.tt>=1){var e=this.buffers[0].getContext(`2d`);this.clearCanvas(e),e.drawImage(this.canvasContext.canvas,0,0),this.currentTransform=this.canvasContext.getTransform(),this.canvasContext.setTransform(1,0,0,1,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.setTransform(this.currentTransform)}},exitLayer:function(){if(this.data.tt>=1){var e=this.buffers[1],t=e.getContext(`2d`);if(this.clearCanvas(t),t.drawImage(this.canvasContext.canvas,0,0),this.canvasContext.setTransform(1,0,0,1,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.setTransform(this.currentTransform),this.comp.getElementById(`tp`in this.data?this.data.tp:this.data.ind-1).renderFrame(!0),this.canvasContext.setTransform(1,0,0,1,0,0),this.data.tt>=3&&!document._isProxy){var n=lr.getLumaCanvas(this.canvasContext.canvas);n.getContext(`2d`).drawImage(this.canvasContext.canvas,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.drawImage(n,0,0)}this.canvasContext.globalCompositeOperation=mr[this.data.tt],this.canvasContext.drawImage(e,0,0),this.canvasContext.globalCompositeOperation=`destination-over`,this.canvasContext.drawImage(this.buffers[0],0,0),this.canvasContext.setTransform(this.currentTransform),this.canvasContext.globalCompositeOperation=`source-over`}},renderFrame:function(e){if(!(this.hidden||this.data.hd)&&!(this.data.td===1&&!e)){this.renderTransform(),this.renderRenderable(),this.renderLocalTransform(),this.setBlendMode();var t=this.data.ty===0;this.prepareLayer(),this.globalData.renderer.save(t),this.globalData.renderer.ctxTransform(this.finalTransform.localMat.props),this.globalData.renderer.ctxOpacity(this.finalTransform.localOpacity),this.renderInnerContent(),this.globalData.renderer.restore(t),this.exitLayer(),this.maskManager.hasMasks&&this.globalData.renderer.restore(!0),this._isFirstFrame&&=!1}},destroy:function(){this.canvasContext=null,this.data=null,this.globalData=null,this.maskManager.destroy()},mHelper:new K},pr.prototype.hide=pr.prototype.hideElement,pr.prototype.show=pr.prototype.showElement;function hr(e,t,n,r){this.styledShapes=[],this.tr=[0,0,0,0,0,0];var i=4;t.ty===`rc`?i=5:t.ty===`el`?i=6:t.ty===`sr`&&(i=7),this.sh=G.getShapeProp(e,t,i,e);var a,o=n.length,s;for(a=0;a=0;--a){if(d=this.searchProcessedElement(e[a]),d?t[a]=n[d-1]:e[a]._shouldRender=r,e[a].ty===`fl`||e[a].ty===`st`||e[a].ty===`gf`||e[a].ty===`gs`)d?t[a].style.closed=!1:t[a]=this.createStyleElement(e[a],m),l.push(t[a].style);else if(e[a].ty===`gr`){if(!d)t[a]=this.createGroupElement(e[a]);else for(c=t[a].it.length,s=0;s=0;--i)t[i].ty===`tr`?(o=n[i].transform,this.renderShapeTransform(e,o)):t[i].ty===`sh`||t[i].ty===`el`||t[i].ty===`rc`||t[i].ty===`sr`?this.renderPath(t[i],n[i]):t[i].ty===`fl`?this.renderFill(t[i],n[i],o):t[i].ty===`st`?this.renderStroke(t[i],n[i],o):t[i].ty===`gf`||t[i].ty===`gs`?this.renderGradientFill(t[i],n[i],o):t[i].ty===`gr`?this.renderShape(o,t[i].it,n[i].it):t[i].ty;r&&this.drawLayer()},gr.prototype.renderStyledShape=function(e,t){if(this._isFirstFrame||t._mdf||e.transforms._mdf){var n=e.trNodes,r=t.paths,i,a,o,s=r._length;n.length=0;var c=e.transforms.finalTransform;for(o=0;o=1?u=.99:u<=-1&&(u=-.99);var d=c*u,f=Math.cos(l+t.a.v)*d+o[0],p=Math.sin(l+t.a.v)*d+o[1];i=a.createRadialGradient(f,p,0,o[0],o[1],c)}var m,h=e.g.p,g=t.g.c,_=1;for(m=0;ma&&c===`xMidYMid slice`||ii&&s===`meet`||ai&&s===`slice`)?this.transformCanvas.tx=(n-this.transformCanvas.w*(r/this.transformCanvas.h))/2*this.renderConfig.dpr:l===`xMax`&&(ai&&s===`slice`)?this.transformCanvas.tx=(n-this.transformCanvas.w*(r/this.transformCanvas.h))*this.renderConfig.dpr:this.transformCanvas.tx=0,u===`YMid`&&(a>i&&s===`meet`||ai&&s===`meet`||a=0;--e)this.elements[e]&&this.elements[e].destroy&&this.elements[e].destroy();this.elements.length=0,this.globalData.canvasContext=null,this.animationItem.container=null,this.destroyed=!0},Z.prototype.renderFrame=function(e,t){if(!(this.renderedFrame===e&&this.renderConfig.clearCanvas===!0&&!t||this.destroyed||e===-1)){this.renderedFrame=e,this.globalData.frameNum=e-this.animationItem._isFirstFrame,this.globalData.frameId+=1,this.globalData._mdf=!this.renderConfig.clearCanvas||t,this.globalData.projectInterface.currentFrame=e;var n,r=this.layers.length;for(this.completeLayers||this.checkLayers(e),n=r-1;n>=0;--n)(this.completeLayers||this.elements[n])&&this.elements[n].prepareFrame(e-this.layers[n].st);if(this.globalData._mdf){for(this.renderConfig.clearCanvas===!0?this.canvasContext.clearRect(0,0,this.transformCanvas.w,this.transformCanvas.h):this.save(),n=r-1;n>=0;--n)(this.completeLayers||this.elements[n])&&this.elements[n].renderFrame();this.renderConfig.clearCanvas!==!0&&this.restore()}}},Z.prototype.buildItem=function(e){var t=this.elements;if(!(t[e]||this.layers[e].ty===99)){var n=this.createItem(this.layers[e],this,this.globalData);t[e]=n,n.initExpressions()}},Z.prototype.checkPendingElements=function(){for(;this.pendingElements.length;)this.pendingElements.pop().checkParenting()},Z.prototype.hide=function(){this.animationItem.container.style.display=`none`},Z.prototype.show=function(){this.animationItem.container.style.display=`block`};function br(){this.opacity=-1,this.transform=p(`float32`,16),this.fillStyle=``,this.strokeStyle=``,this.lineWidth=``,this.lineCap=``,this.lineJoin=``,this.miterLimit=``,this.id=Math.random()}function xr(){this.stack=[],this.cArrPos=0,this.cTr=new K;var e,t=15;for(e=0;e=0;--t)(this.completeLayers||this.elements[t])&&this.elements[t].renderFrame()},Sr.prototype.destroy=function(){var e;for(e=this.layers.length-1;e>=0;--e)this.elements[e]&&this.elements[e].destroy();this.layers=null,this.elements=null},Sr.prototype.createComp=function(e){return new Sr(e,this.globalData,this)};function Cr(e,t){this.animationItem=e,this.renderConfig={clearCanvas:t&&t.clearCanvas!==void 0?t.clearCanvas:!0,context:t&&t.context||null,progressiveLoad:t&&t.progressiveLoad||!1,preserveAspectRatio:t&&t.preserveAspectRatio||`xMidYMid meet`,imagePreserveAspectRatio:t&&t.imagePreserveAspectRatio||`xMidYMid slice`,contentVisibility:t&&t.contentVisibility||`visible`,className:t&&t.className||``,id:t&&t.id||``,runExpressions:!t||t.runExpressions===void 0||t.runExpressions},this.renderConfig.dpr=t&&t.dpr||1,this.animationItem.wrapper&&(this.renderConfig.dpr=t&&t.dpr||window.devicePixelRatio||1),this.renderedFrame=-1,this.globalData={frameNum:-1,_mdf:!1,renderConfig:this.renderConfig,currentGlobalAlpha:-1},this.contextData=new xr,this.elements=[],this.pendingElements=[],this.transformMat=new K,this.completeLayers=!1,this.rendererType=`canvas`,this.renderConfig.clearCanvas&&(this.ctxTransform=this.contextData.transform.bind(this.contextData),this.ctxOpacity=this.contextData.opacity.bind(this.contextData),this.ctxFillStyle=this.contextData.fillStyle.bind(this.contextData),this.ctxStrokeStyle=this.contextData.strokeStyle.bind(this.contextData),this.ctxLineWidth=this.contextData.lineWidth.bind(this.contextData),this.ctxLineCap=this.contextData.lineCap.bind(this.contextData),this.ctxLineJoin=this.contextData.lineJoin.bind(this.contextData),this.ctxMiterLimit=this.contextData.miterLimit.bind(this.contextData),this.ctxFill=this.contextData.fill.bind(this.contextData),this.ctxFillRect=this.contextData.fillRect.bind(this.contextData),this.ctxStroke=this.contextData.stroke.bind(this.contextData),this.save=this.contextData.save.bind(this.contextData))}return u([Z],Cr),Cr.prototype.createComp=function(e){return new Sr(e,this.globalData,this)},_e(`canvas`,Cr),dt.registerModifier(`tm`,pt),dt.registerModifier(`pb`,mt),dt.registerModifier(`rp`,gt),dt.registerModifier(`rd`,_t),dt.registerModifier(`zz`,Ft),dt.registerModifier(`op`,Jt),J}))}))(),1),_t=0,vt=e=>`${e}-${++_t}`,yt=e=>({key:vt(e),name:``,rarity:`1000`,sortOrder:`0`,file:null,animation:null,fileError:``}),bt=()=>({key:vt(`backdrop`),name:``,backdropID:`1`,rarity:`1000`,sortOrder:`0`,center:`#6f5bea`,edge:`#34278f`,pattern:`#a89df5`,text:`#ffffff`});function xt({data:e,compact:t=!1}){let n=(0,g.useRef)(null);return(0,g.useEffect)(()=>{if(!n.current)return;let t=gt.default.loadAnimation({container:n.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)});return()=>t.destroy()},[e]),(0,V.jsx)(`div`,{className:`collectible-animation ${t?`compact`:``}`,ref:n})}function St({giftID:e,attribute:t}){let[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(!1);return(0,g.useEffect)(()=>{let n=!1;return a(!1),x.giftCollectibleAnimation(e,t.kind,t.id).then(e=>{n||r(e)}).catch(()=>{n||a(!0)}),()=>{n=!0}},[e,t.id,t.kind]),i?(0,V.jsx)(`div`,{className:`collectible-animation compact failed`,children:`!`}):n?(0,V.jsx)(xt,{data:n,compact:!0}):(0,V.jsx)(`div`,{className:`collectible-animation compact loading`,children:(0,V.jsx)(A,{className:`spin`,size:15})})}async function Ct(e){let t=new Uint8Array(await e.arrayBuffer()),n=t;if(t.length>=2&&t[0]===31&&t[1]===139){if(!(`DecompressionStream`in window))throw Error(`This browser cannot preview TGS files`);let e=new Blob([t]).stream().pipeThrough(new DecompressionStream(`gzip`));n=new Uint8Array(await new Response(e).arrayBuffer())}let r=JSON.parse(new TextDecoder().decode(n));if(!r||typeof r!=`object`||Array.isArray(r))throw Error(`Invalid Lottie JSON`);return r}var wt=e=>Number.parseInt(e.replace(`#`,``),16);function Tt({gift:e,onClose:t,onPublished:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(!0),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``),[f,p]=(0,g.useState)(null),[m,h]=(0,g.useState)(`100`),[_,v]=(0,g.useState)(`1000`),[y,S]=(0,g.useState)(`gift-${e.GiftID}`),[C,w]=(0,g.useState)(``),[T,E]=(0,g.useState)([yt(`model`)]),[D,O]=(0,g.useState)([yt(`pattern`)]),[M,N]=(0,g.useState)([bt()]);(0,g.useEffect)(()=>{let t=!1;return x.giftCollectibles(e.GiftID).then(n=>{t||(a(n),n.found&&(h(String(n.upgrade_stars??100)),v(String(n.supply_total??1e3)),S(n.slug_prefix??`gift-${e.GiftID}`)))}).catch(e=>d(b(e))).finally(()=>{t||s(!1)}),()=>{t=!0}},[e.GiftID]);let P=(0,g.useMemo)(()=>({models:T.reduce((e,t)=>e+Number(t.rarity||0),0),patterns:D.reduce((e,t)=>e+Number(t.rarity||0),0),backdrops:M.reduce((e,t)=>e+Number(t.rarity||0),0)}),[T,D,M]),F=()=>p(null),I=(e,t,n)=>{(e===`models`?E:O)(e=>e.map(e=>e.key===t?{...e,...n}:e)),F()};async function L(e,t,n){if(I(e,t.key,{file:n,animation:null,fileError:``}),n)try{let r=await Ct(n);I(e,t.key,{animation:r,fileError:``})}catch(n){I(e,t.key,{animation:null,fileError:b(n)})}}function ee(e,t=``){if(!C.trim())throw Error(r(`action.reasonRequired`));for(let e of[...T,...D])if(!e.file)throw Error(r(`collectibles.fileRequired`));let n=new FormData,i=e=>e.map(e=>({name:e.name.trim(),rarity_permille:Number(e.rarity),sort_order:Number(e.sortOrder),file_key:e.key}));n.set(`metadata`,JSON.stringify({command_id:t,reason:C.trim(),confirm:e,upgrade_stars:Number(m),supply_total:Number(_),slug_prefix:y.trim().toLowerCase(),models:i(T),patterns:i(D),backdrops:M.map(e=>({name:e.name.trim(),backdrop_id:Number(e.backdropID),rarity_permille:Number(e.rarity),sort_order:Number(e.sortOrder),center_color:wt(e.center),edge_color:wt(e.edge),pattern_color:wt(e.pattern),text_color:wt(e.text)}))}));for(let e of[...T,...D])n.set(e.key,e.file,e.file.name);return n}async function te(){l(!0),d(``),p(null);try{p(await x.publishGiftCollectibles(e.GiftID,ee(!1)))}catch(e){d(b(e))}finally{l(!1)}}async function re(){if(f){l(!0),d(``);try{await x.publishGiftCollectibles(e.GiftID,ee(!0,f.command_id)),n(),t()}catch(e){d(b(e))}finally{l(!1)}}}let ie=(e,t,n)=>(0,V.jsxs)(`section`,{className:`collectible-section`,children:[(0,V.jsxs)(`div`,{className:`collectible-section-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.${e}`)}),(0,V.jsx)(`span`,{children:r(`collectibles.rarityHint`)})]}),(0,V.jsxs)(`div`,{className:`collectible-section-tools`,children:[(0,V.jsxs)(G,{tone:P[e]===1e3?`good`:`neutral`,children:[P[e],` / 1000`]}),(0,V.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>{n([...t,yt(e===`models`?`model`:`pattern`)]),F()},children:[(0,V.jsx)(z,{size:13}),r(`collectibles.addAttribute`)]})]})]}),(0,V.jsx)(`div`,{className:`collectible-rows`,children:t.map((i,a)=>(0,V.jsxs)(`div`,{className:`collectible-row animated`,children:[(0,V.jsx)(`div`,{className:`collectible-row-index`,children:a+1}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`common.name`)}),(0,V.jsx)(`input`,{value:i.name,maxLength:128,onChange:t=>I(e,i.key,{name:t.target.value})})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.rarity`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,max:`1000`,value:i.rarity,onChange:t=>I(e,i.key,{rarity:t.target.value})})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`gifts.sortOrder`)}),(0,V.jsx)(`input`,{type:`number`,value:i.sortOrder,onChange:t=>I(e,i.key,{sortOrder:t.target.value})})]}),(0,V.jsxs)(`label`,{className:`collectible-file`,children:[(0,V.jsx)(`span`,{children:r(`gifts.animation`)}),(0,V.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.lottie,application/json,application/x-tgsticker`,onChange:t=>void L(e,i,t.target.files?.[0]??null)}),(0,V.jsxs)(`em`,{children:[(0,V.jsx)(R,{size:13}),i.file?.name??r(`gifts.chooseFile`)]})]}),(0,V.jsx)(`div`,{className:`collectible-inline-preview`,children:i.animation?(0,V.jsx)(xt,{data:i.animation,compact:!0}):(0,V.jsx)(j,{size:16})}),(0,V.jsx)(`button`,{className:`icon-btn danger`,type:`button`,disabled:t.length===1,onClick:()=>{n(t.filter(e=>e.key!==i.key)),F()},"aria-label":r(`collectibles.remove`),children:(0,V.jsx)(ge,{size:14})}),i.fileError&&(0,V.jsx)(`span`,{className:`collectible-file-error`,children:i.fileError})]},i.key))})]});return(0,Qe.createPortal)((0,V.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,V.jsxs)(`section`,{className:`modal command-modal collectible-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":r(`collectibles.title`,{id:e.GiftID}),children:[(0,V.jsxs)(`div`,{className:`modal-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`eyebrow`,children:r(`collectibles.eyebrow`)}),(0,V.jsx)(`h2`,{children:r(`collectibles.title`,{id:e.GiftID})}),(0,V.jsx)(`p`,{children:e.Title||`Gift #${e.GiftID}`})]}),(0,V.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:t,disabled:c,"aria-label":r(`action.close`),children:(0,V.jsx)(ye,{size:15})})]}),(0,V.jsxs)(`div`,{className:`command-body collectible-modal-body`,children:[o?(0,V.jsxs)(`div`,{className:`collectible-loading`,children:[(0,V.jsx)(A,{className:`spin`}),r(`common.loading`)]}):i?.found?(0,V.jsxs)(`section`,{className:`collectible-active`,children:[(0,V.jsxs)(`div`,{className:`collectible-active-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(ne,{size:18}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.activeRevision`,{revision:i.revision??0})}),(0,V.jsxs)(`span`,{children:[i.slug_prefix,` · ⭐ `,i.upgrade_stars,` · `,i.issued,` / `,i.supply_total]})]})]}),(0,V.jsx)(G,{tone:`good`,children:r(`collectibles.published`)})]}),(0,V.jsxs)(`div`,{className:`collectible-active-grid`,children:[[...i.models??[],...i.patterns??[]].map(t=>(0,V.jsxs)(`article`,{children:[(0,V.jsx)(St,{giftID:e.GiftID,attribute:t}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:t.name}),(0,V.jsxs)(`span`,{children:[r(`collectibles.${t.kind}`),` · `,t.rarity_permille,`‰`]})]})]},`${t.kind}-${t.id}`)),(i.backdrops??[]).map(e=>(0,V.jsxs)(`article`,{children:[(0,V.jsx)(`div`,{className:`collectible-backdrop-preview`,style:{background:`radial-gradient(circle, #${(e.center_color??0).toString(16).padStart(6,`0`)}, #${(e.edge_color??0).toString(16).padStart(6,`0`)})`,color:`#${(e.text_color??16777215).toString(16).padStart(6,`0`)}`},children:`Aa`}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:e.name}),(0,V.jsxs)(`span`,{children:[r(`collectibles.backdrop`),` · `,e.rarity_permille,`‰`]})]})]},`backdrop-${e.id}`))]})]}):(0,V.jsxs)(`div`,{className:`collectible-empty`,children:[(0,V.jsx)(ne,{size:22}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.noPool`)}),(0,V.jsx)(`span`,{children:r(`collectibles.noPoolHint`)})]})]}),(0,V.jsxs)(`section`,{className:`collectible-definition`,children:[(0,V.jsxs)(`div`,{className:`collectible-definition-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.publishNew`)}),(0,V.jsx)(`span`,{children:r(`collectibles.immutableHint`)})]}),(0,V.jsxs)(`div`,{className:`gift-format-chips`,children:[(0,V.jsx)(`span`,{children:`TGS`}),(0,V.jsx)(`span`,{children:`Lottie JSON`})]})]}),(0,V.jsxs)(`div`,{className:`gift-fields-grid collectible-main-fields`,children:[(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.upgradeStars`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,value:m,onChange:e=>{h(e.target.value),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.supply`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,value:_,onChange:e=>{v(e.target.value),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.slug`)}),(0,V.jsx)(`input`,{value:y,maxLength:48,onChange:e=>{S(e.target.value.toLowerCase()),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`gifts.reason`)}),(0,V.jsx)(`input`,{value:C,maxLength:1e3,placeholder:r(`gifts.reasonPlaceholder`),onChange:e=>w(e.target.value)})]})]}),ie(`models`,T,E),ie(`patterns`,D,O),(0,V.jsxs)(`section`,{className:`collectible-section`,children:[(0,V.jsxs)(`div`,{className:`collectible-section-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.backdrops`)}),(0,V.jsx)(`span`,{children:r(`collectibles.colorHint`)})]}),(0,V.jsxs)(`div`,{className:`collectible-section-tools`,children:[(0,V.jsxs)(G,{tone:P.backdrops===1e3?`good`:`neutral`,children:[P.backdrops,` / 1000`]}),(0,V.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>{N([...M,bt()]),F()},children:[(0,V.jsx)(z,{size:13}),r(`collectibles.addAttribute`)]})]})]}),(0,V.jsx)(`div`,{className:`collectible-rows`,children:M.map((e,t)=>(0,V.jsxs)(`div`,{className:`collectible-row backdrop`,children:[(0,V.jsx)(`div`,{className:`collectible-row-index`,children:t+1}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`common.name`)}),(0,V.jsx)(`input`,{value:e.name,maxLength:128,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,name:t.target.value}:n)),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.backdropID`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,value:e.backdropID,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,backdropID:t.target.value}:n)),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.rarity`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,max:`1000`,value:e.rarity,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,rarity:t.target.value}:n)),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`gifts.sortOrder`)}),(0,V.jsx)(`input`,{type:`number`,value:e.sortOrder,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,sortOrder:t.target.value}:n)),F()}})]}),[`center`,`edge`,`pattern`,`text`].map(t=>(0,V.jsxs)(`label`,{className:`collectible-color`,children:[(0,V.jsx)(`span`,{children:r(`collectibles.color.${t}`)}),(0,V.jsx)(`input`,{type:`color`,value:e[t],onChange:n=>{N(M.map(r=>r.key===e.key?{...r,[t]:n.target.value}:r)),F()}})]},t)),(0,V.jsx)(`div`,{className:`collectible-backdrop-preview`,style:{background:`radial-gradient(circle, ${e.center}, ${e.edge})`,color:e.text},children:`Aa`}),(0,V.jsx)(`button`,{className:`icon-btn danger`,type:`button`,disabled:M.length===1,onClick:()=>{N(M.filter(t=>t.key!==e.key)),F()},"aria-label":r(`collectibles.remove`),children:(0,V.jsx)(ge,{size:14})})]},e.key))})]})]}),u&&(0,V.jsx)(Ke,{children:u}),f&&(0,V.jsxs)(`div`,{className:`gift-validation`,children:[(0,V.jsxs)(`div`,{className:`gift-validation-head`,children:[(0,V.jsx)(k,{size:17}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.validationReady`)}),(0,V.jsx)(`span`,{children:r(`collectibles.validationHint`)})]})]}),(0,V.jsx)(`pre`,{children:JSON.stringify(f.details,null,2)})]})]}),(0,V.jsxs)(`div`,{className:`modal-actions`,children:[(0,V.jsx)(`button`,{className:`btn`,type:`button`,onClick:t,disabled:c,children:r(`common.close`)}),(0,V.jsxs)(`button`,{className:`btn`,type:`button`,onClick:te,disabled:c,children:[c?(0,V.jsx)(A,{className:`spin`,size:15}):(0,V.jsx)(pe,{size:15}),r(`gifts.validate`)]}),(0,V.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:re,disabled:c||!f,children:[(0,V.jsx)(_e,{size:15}),r(`collectibles.publish`)]})]})]})}),document.body)}function Et(e){return e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:`${(e/(1024*1024)).toFixed(1)} MB`}function Dt({giftID:e,revision:t,compact:n=!1}){let r=(0,g.useRef)(null),i=(0,g.useRef)(null),[a,o]=(0,g.useState)(!0),[s,c]=(0,g.useState)(``);(0,g.useEffect)(()=>{let t=!1;return x.giftAnimation(e).then(e=>{t||!r.current||(i.current?.destroy(),i.current=gt.default.loadAnimation({container:r.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)}))}).catch(e=>c(b(e))),()=>{t=!0,i.current?.destroy(),i.current=null}},[e,t]);function l(){i.current&&(a?i.current.pause():i.current.play(),o(!a))}return(0,V.jsxs)(`div`,{className:`gift-animation-shell ${n?`compact`:``}`,children:[(0,V.jsx)(`div`,{className:`gift-animation`,ref:r,children:s&&(0,V.jsx)(`span`,{children:s})}),(0,V.jsx)(`button`,{className:`gift-play`,type:`button`,onClick:l,"aria-label":a?`Pause`:`Play`,children:a?(0,V.jsx)(le,{size:14}):(0,V.jsx)(ue,{size:14})})]})}function Ot(){let{t:e}=U(),[t,n]=(0,g.useState)([]),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(!1),[s,c]=(0,g.useState)(null),[l,u]=(0,g.useState)(null),[d,f]=(0,g.useState)(0),[p,m]=(0,g.useState)(``),[h,_]=(0,g.useState)(`50`),[v,y]=(0,g.useState)(`50`),[S,C]=(0,g.useState)(`0`),[w,T]=(0,g.useState)(!0),[E,D]=(0,g.useState)(``),[O,j]=(0,g.useState)(null),[M,N]=(0,g.useState)(!1),[P,F]=(0,g.useState)(``),[I,L]=(0,g.useState)(``);async function ee(){F(``);try{n((await x.gifts()).Gifts??[])}catch(e){F(b(e))}}(0,g.useEffect)(()=>{ee()},[]);let te=(0,g.useMemo)(()=>{let e=r.trim().toLowerCase();return e?t.filter(t=>String(t.GiftID).includes(e)||t.Title.toLowerCase().includes(e)||t.SourceFormat.toLowerCase().includes(e)):t},[t,r]);function re(t,n=``){if(!l)throw Error(e(`gifts.fileRequired`));if(!E.trim())throw Error(e(`action.reasonRequired`));let r=new FormData;return r.set(`metadata`,JSON.stringify({command_id:n,reason:E.trim(),confirm:t,gift_id:d,title:p.trim(),stars:Number(h),convert_stars:Number(v),enabled:w,sort_order:Number(S)})),r.set(`file`,l,l.name),r}async function ie(){N(!0),L(``),j(null);try{j(await x.importGift(re(!1)))}catch(e){L(b(e))}finally{N(!1)}}async function ae(){if(O){N(!0),L(``);try{await x.importGift(re(!0,O.command_id)),j(null),u(null),f(0),m(``),await ee(),o(!1)}catch(e){L(b(e))}finally{N(!1)}}}function oe(){f(0),m(``),_(`50`),y(`50`),C(`0`),T(!0),D(``),u(null),j(null),L(``),o(!0)}function se(e){f(e.GiftID),m(e.Title),_(String(e.Stars)),y(String(e.ConvertStars)),C(String(e.SortOrder)),T(e.Enabled),D(``),u(null),j(null),L(``),o(!0)}return(0,V.jsxs)(He,{title:e(`gifts.pageTitle`),eyebrow:e(`gifts.eyebrow`),actions:(0,V.jsxs)(V.Fragment,{children:[(0,V.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>ee(),disabled:M,children:[(0,V.jsx)(de,{size:15}),` `,e(`common.refresh`)]}),(0,V.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:oe,children:[(0,V.jsx)(z,{size:15}),` `,e(`gifts.add`)]})]}),children:[P&&(0,V.jsx)(Ke,{children:P}),(0,V.jsxs)(`div`,{className:`metric-row gift-metrics`,children:[(0,V.jsx)(q,{label:e(`gifts.total`),value:String(t.length)}),(0,V.jsx)(q,{label:e(`gifts.enabled`),value:String(t.filter(e=>e.Enabled).length),tone:`good`}),(0,V.jsx)(q,{label:e(`gifts.received`),value:String(t.reduce((e,t)=>e+t.ReceivedCount,0))}),(0,V.jsx)(q,{label:e(`gifts.formats`),value:`TGS / Lottie`})]}),(0,V.jsx)(Ue,{children:(0,V.jsxs)(`div`,{className:`toolbar`,children:[(0,V.jsxs)(`label`,{className:`searchbox`,children:[(0,V.jsx)(B,{size:15}),(0,V.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),placeholder:e(`gifts.searchPlaceholder`)})]}),(0,V.jsx)(`span`,{className:`gift-list-summary`,children:e(`gifts.listSummary`,{shown:te.length,total:t.length})})]})}),(0,V.jsx)(`div`,{className:`table-wrap gift-table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table gift-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:e(`gifts.animation`)}),(0,V.jsx)(`th`,{children:e(`gifts.idRevision`)}),(0,V.jsx)(`th`,{children:e(`gifts.title`)}),(0,V.jsx)(`th`,{children:e(`gifts.price`)}),(0,V.jsx)(`th`,{children:e(`gifts.source`)}),(0,V.jsx)(`th`,{children:e(`gifts.received`)}),(0,V.jsx)(`th`,{children:e(`common.status`)}),(0,V.jsx)(`th`,{children:e(`common.updatedAt`)}),(0,V.jsx)(`th`,{children:e(`common.actions`)})]})}),(0,V.jsxs)(`tbody`,{children:[te.map(t=>(0,V.jsxs)(`tr`,{className:t.Enabled?``:`gift-row-disabled`,children:[(0,V.jsx)(`td`,{children:(0,V.jsx)(Dt,{giftID:t.GiftID,revision:t.Revision,compact:!0})}),(0,V.jsxs)(`td`,{className:`mono`,children:[t.GiftID,` / `,t.Revision]}),(0,V.jsxs)(`td`,{children:[(0,V.jsx)(`strong`,{className:`gift-table-title`,children:t.Title||`Gift #${t.GiftID}`}),(0,V.jsxs)(`span`,{className:`gift-sort-order`,children:[e(`gifts.sortOrder`),`: `,t.SortOrder]})]}),(0,V.jsxs)(`td`,{children:[(0,V.jsxs)(`strong`,{className:`gift-table-price`,children:[`⭐ `,t.Stars]}),(0,V.jsxs)(`span`,{className:`gift-convert-price`,children:[`→ `,t.ConvertStars]})]}),(0,V.jsxs)(`td`,{children:[(0,V.jsx)(G,{children:t.SourceFormat}),(0,V.jsx)(`span`,{className:`gift-source-size`,children:Et(t.AnimationSize)})]}),(0,V.jsx)(`td`,{children:t.ReceivedCount}),(0,V.jsx)(`td`,{children:(0,V.jsx)(G,{tone:t.Enabled?`good`:`neutral`,children:t.Enabled?e(`common.enabled`):e(`common.disabled`)})}),(0,V.jsx)(`td`,{children:ze(t.UpdatedAt)}),(0,V.jsx)(`td`,{children:(0,V.jsxs)(`div`,{className:`gift-table-actions`,children:[(0,V.jsxs)(`button`,{className:`btn compact-btn collectible-button`,type:`button`,onClick:()=>c(t),children:[(0,V.jsx)(ne,{size:13}),e(`collectibles.manage`)]}),(0,V.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>se(t),children:e(`gifts.replace`)}),(0,V.jsx)($e,{compact:!0,tone:`neutral`,label:t.Enabled?e(`gifts.disable`):e(`gifts.enable`),path:`/api/actions/set-gift-enabled`,payload:()=>({gift_id:t.GiftID,enabled:!t.Enabled}),onDone:()=>void ee()})]})})]},t.GiftID)),te.length===0&&(0,V.jsx)(Je,{colSpan:9})]})]})}),a&&(0,Qe.createPortal)((0,V.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,V.jsxs)(`section`,{className:`modal command-modal gift-import-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":d?e(`gifts.newRevision`,{id:d}):e(`gifts.importTitle`),children:[(0,V.jsxs)(`div`,{className:`modal-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`eyebrow`,children:e(`gifts.importEyebrow`)}),(0,V.jsx)(`h2`,{children:d?e(`gifts.newRevision`,{id:d}):e(`gifts.importTitle`)})]}),(0,V.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>o(!1),disabled:M,"aria-label":e(`action.close`),children:(0,V.jsx)(ye,{size:15})})]}),(0,V.jsxs)(`div`,{className:`command-body gift-import-modal-body`,children:[(0,V.jsxs)(`div`,{className:`command-steps`,children:[(0,V.jsxs)(`div`,{className:`command-step ${l?`done`:`active`}`,children:[(0,V.jsx)(`span`,{children:`1`}),(0,V.jsx)(`strong`,{children:e(`gifts.stepDetails`)})]}),(0,V.jsxs)(`div`,{className:`command-step ${O?`done`:l?`active`:``}`,children:[(0,V.jsx)(`span`,{children:`2`}),(0,V.jsx)(`strong`,{children:e(`gifts.stepValidate`)})]}),(0,V.jsxs)(`div`,{className:`command-step ${O?`active`:``}`,children:[(0,V.jsx)(`span`,{children:`3`}),(0,V.jsx)(`strong`,{children:e(`gifts.stepImport`)})]})]}),(0,V.jsxs)(`div`,{className:`gift-import-note`,children:[(0,V.jsx)(`span`,{children:e(`gifts.importHint`)}),(0,V.jsxs)(`div`,{className:`gift-format-chips`,"aria-label":e(`gifts.formats`),children:[(0,V.jsx)(`span`,{children:`TGS`}),(0,V.jsx)(`span`,{children:`Lottie JSON`})]})]}),(0,V.jsxs)(`label`,{className:`gift-file-picker ${l?`has-file`:``}`,children:[(0,V.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.lottie,application/json,application/x-tgsticker`,onChange:e=>{u(e.target.files?.[0]??null),j(null)}}),(0,V.jsx)(`span`,{className:`gift-file-icon`,children:(0,V.jsx)(R,{size:22})}),(0,V.jsxs)(`span`,{className:`gift-file-copy`,children:[(0,V.jsx)(`span`,{className:`gift-field-label`,children:e(`gifts.animation`)}),(0,V.jsx)(`strong`,{children:l?l.name:e(`gifts.filePrompt`)}),(0,V.jsx)(`small`,{children:l?Et(l.size):e(`gifts.fileHint`)})]}),(0,V.jsx)(`span`,{className:`gift-file-action`,children:e(l?`gifts.changeFile`:`gifts.chooseFile`)})]}),(0,V.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:e(`gifts.title`)}),(0,V.jsx)(`input`,{value:p,maxLength:128,placeholder:e(`gifts.titlePlaceholder`),onChange:e=>{m(e.target.value),j(null)}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:e(`gifts.stars`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,value:h,onChange:e=>{_(e.target.value),j(null)}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:e(`gifts.convertStars`)}),(0,V.jsx)(`input`,{type:`number`,min:`0`,value:v,onChange:e=>{y(e.target.value),j(null)}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:e(`gifts.sortOrder`)}),(0,V.jsx)(`input`,{type:`number`,value:S,onChange:e=>{C(e.target.value),j(null)}})]})]}),(0,V.jsxs)(`label`,{className:`gift-reason-field`,children:[(0,V.jsx)(`span`,{children:e(`gifts.reason`)}),(0,V.jsx)(`input`,{value:E,placeholder:e(`gifts.reasonPlaceholder`),onChange:e=>D(e.target.value)})]}),(0,V.jsxs)(`label`,{className:`gift-switch`,children:[(0,V.jsx)(`input`,{type:`checkbox`,checked:w,onChange:e=>{T(e.target.checked),j(null)}}),(0,V.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,V.jsx)(`span`,{})}),(0,V.jsx)(`span`,{children:e(`gifts.enableAfterImport`)})]}),I&&(0,V.jsx)(Ke,{children:I}),O&&(0,V.jsxs)(`div`,{className:`gift-validation`,children:[(0,V.jsxs)(`div`,{className:`gift-validation-head`,children:[(0,V.jsx)(k,{size:17}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:e(`gifts.validationReady`)}),(0,V.jsx)(`span`,{children:e(`gifts.validationHint`)})]})]}),(0,V.jsx)(`pre`,{children:JSON.stringify(O.details,null,2)})]})]}),(0,V.jsxs)(`div`,{className:`modal-actions`,children:[(0,V.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>o(!1),disabled:M,children:e(`common.close`)}),(0,V.jsxs)(`button`,{className:`btn`,type:`button`,onClick:ie,disabled:M,children:[M?(0,V.jsx)(A,{className:`spin`,size:15}):(0,V.jsx)(pe,{size:15}),e(`gifts.validate`)]}),(0,V.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:ae,disabled:M||!O,children:[(0,V.jsx)(_e,{size:15}),e(`gifts.confirmImport`)]})]})]})}),document.body),s&&(0,V.jsx)(Tt,{gift:s,onClose:()=>c(null),onPublished:()=>void ee()})]})}function kt({route:e,navigate:t}){let n=e.path.match(/^\/accounts\/(\d+)$/)?.[1],r=e.path.match(/^\/channels\/(\d+)$/)?.[1];return n?(0,V.jsx)(tt,{id:Number(n),navigate:t}):r?(0,V.jsx)(ot,{id:Number(r),navigate:t}):e.path===`/accounts`?(0,V.jsx)(at,{navigate:t}):e.path===`/channels`?(0,V.jsx)(st,{navigate:t}):e.path===`/gifts`?(0,V.jsx)(Ot,{}):e.path===`/messages/detail`||e.path===`/messages/private/detail`?(0,V.jsx)(mt,{ownerUserID:Number(e.search.get(`owner_user_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups/detail`?(0,V.jsx)(ut,{channelID:Number(e.search.get(`channel_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups`?(0,V.jsx)(pt,{navigate:t}):e.path===`/messages`||e.path===`/messages/private`?(0,V.jsx)(ht,{navigate:t}):(0,V.jsx)(ct,{navigate:t})}function At(){let[e,t]=(0,g.useState)(void 0),[n,r]=(0,g.useState)(()=>Oe());(0,g.useEffect)(()=>{let e=()=>r(Oe());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,g.useEffect)(()=>{x.session().then(e=>t(e.actor)).catch(e=>{if(e instanceof v&&e.status===401){t(null);return}t(null)})},[]);let i=e=>{window.history.pushState(null,``,e),r(Oe())};return e===void 0?(0,V.jsx)(Me,{}):e===null?(0,V.jsx)(Ze,{onLogin:t}):(0,V.jsx)(Ne,{actor:e,route:n,navigate:i,onLogout:()=>t(null),children:(0,V.jsx)(kt,{route:n,navigate:i})})}_.createRoot(document.getElementById(`root`)).render((0,V.jsx)(g.StrictMode,{children:(0,V.jsx)(Ce,{children:(0,V.jsx)(At,{})})})); \ No newline at end of file diff --git a/cmd/telesrv-admin/web/dist/assets/index-BaxMq_AT.css b/cmd/telesrv-admin/web/dist/assets/index-BaxMq_AT.css deleted file mode 100644 index 6729d008..00000000 --- a/cmd/telesrv-admin/web/dist/assets/index-BaxMq_AT.css +++ /dev/null @@ -1 +0,0 @@ -:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:#f3f5f7;--panel:#fff;--panel-subtle:#f8fafb;--panel-strong:#eef2f5;--line:#d9e1e8;--line-strong:#c2ccd6;--text:#101828;--muted:#667085;--muted-2:#98a2b3;--brand:#176d61;--brand-2:#245b9d;--good:#167447;--warn:#a15c07;--danger:#b42318;--sidebar:#11161d;--sidebar-soft:#1b222b;--sidebar-line:#2c3541;--focus:#176d6129;--shadow:0 18px 52px #10182824}*{box-sizing:border-box}html,body,#root{min-height:100%}body{color:var(--text);background:var(--bg);margin:0;font:13px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}button,input,textarea{font:inherit}a{color:inherit;text-decoration:none}.shell{grid-template-columns:232px minmax(0,1fr);min-height:100vh;display:grid}.sidebar{color:#eef2f6;background:var(--sidebar);border-right:1px solid var(--sidebar-line);flex-direction:column;gap:16px;height:100vh;padding:18px 12px;display:flex;position:sticky;top:0;overflow-y:auto}.brand{align-items:center;gap:10px;min-height:42px;padding:0 4px;display:flex}.brand.compact{justify-content:center}.brand-elevated .brand-mark{box-shadow:0 8px 24px #176d6142}.brand-mark{color:#fff;background:var(--brand);border:1px solid #fff3;border-radius:8px;place-items:center;width:34px;height:34px;font-weight:800;display:grid}.brand strong{font-size:14px;line-height:1.1;display:block}.brand small{color:#aeb8c4;margin-top:3px;font-size:11px;display:block}.sidebar-label{color:#8492a6;text-transform:uppercase;padding:0 8px;font-size:11px;font-weight:700}.nav-list,.nav-section{gap:4px;display:grid}.nav-section-toggle{color:#8fa0b4;cursor:pointer;text-align:left;background:0 0;border:1px solid #0000;border-radius:7px;grid-template-columns:18px minmax(0,1fr) 16px;align-items:center;gap:9px;width:100%;min-height:38px;padding:0 10px;font-size:12px;font-weight:800;display:grid}.nav-section-toggle:hover,.nav-section.active .nav-section-toggle{color:#fff;background:var(--sidebar-soft);border-color:#34404d}.nav-section-chevron{color:#8fa0b4;justify-self:end;transition:transform .14s}.nav-section.open .nav-section-chevron{transform:rotate(180deg)}.nav-children{gap:4px;padding:2px 0 2px 18px;display:grid}.nav-item{color:#c6d0dc;border:1px solid #0000;border-radius:7px;grid-template-columns:18px minmax(0,1fr);align-items:center;gap:9px;min-height:38px;padding:0 10px;display:grid}.nav-dot{background:#687789;border-radius:999px;justify-self:center;width:6px;height:6px}.nav-item:hover,.nav-item.active{color:#fff;background:var(--sidebar-soft);border-color:#34404d}.nav-item.active .nav-dot{background:var(--brand)}.sidebar-status{gap:7px;margin-top:auto;display:grid}.runtime-row{color:#cbd5df;background:#171d25;border:1px solid #27313c;border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;min-height:32px;padding:0 8px;display:grid}.runtime-row strong{color:#fff;font-size:11px}.workspace{min-width:0}.topbar{z-index:20;border-bottom:1px solid var(--line);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);background:#fffffff0;justify-content:space-between;align-items:center;gap:18px;min-height:66px;padding:12px 24px;display:flex;position:sticky;top:0}.topbar h1{margin:2px 0 0;font-size:20px;line-height:1.2}.topbar-actions,.page-actions,.section-action,.entity-badges,.row-actions,.modal-actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.language-switch{background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;align-items:center;min-height:30px;padding:2px;display:inline-flex}.language-switch button{min-width:42px;min-height:24px;color:var(--muted);cursor:pointer;background:0 0;border:0;border-radius:999px;padding:0 9px;font-weight:800}.language-switch button.active{color:#fff;background:var(--brand)}.language-switch button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}.actor-pill{color:#344054;background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;align-items:center;min-height:30px;padding:0 10px;display:inline-flex}.content{gap:16px;padding:18px 24px 30px;display:grid}.eyebrow{color:var(--muted);text-transform:uppercase;font-size:11px;font-weight:800}.dashboard-layout,.stacked-sections{gap:14px;display:grid}.overview-band,.page-frame{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-width:0}.overview-band{grid-template-columns:minmax(220px,1fr) minmax(420px,.9fr);align-items:center;gap:16px;padding:16px;display:grid}.overview-band h2,.page-title-row h2,.section-head h2,.modal h2{margin:0;font-size:18px;line-height:1.25}.overview-metrics,.metric-row{grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;display:grid}.overview-metrics{grid-template-columns:repeat(3,minmax(120px,1fr))}.status-item,.metric,.summary-item{background:var(--panel-subtle);border:1px solid var(--line);border-radius:7px;min-width:0;padding:10px}.status-item span,.metric span,.summary-item span{color:var(--muted);margin-bottom:6px;font-size:11px;display:block}.status-item strong,.metric strong,.summary-item strong{overflow-wrap:anywhere;color:var(--text);font-weight:800;display:block}.status-item.good,.metric.good{border-color:#afd8bf}.status-item.warn,.metric.warn{border-color:#e7c77e}.metric.danger{border-color:#efb4ad}.command-grid{grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px;display:grid}.launcher{background:var(--panel);border:1px solid var(--line);border-radius:8px;grid-template-columns:38px minmax(0,1fr) 18px;align-items:center;gap:12px;min-height:94px;padding:14px;display:grid}.launcher:hover{border-color:var(--brand)}.launcher-icon{width:38px;height:38px;color:var(--brand);background:#edf7f4;border:1px solid #c9e2dc;border-radius:8px;place-items:center;display:grid}.launcher-copy{gap:4px;display:grid}.launcher-copy strong{font-size:15px}.launcher-copy span{color:var(--muted)}.work-strip{grid-template-columns:repeat(4,minmax(160px,1fr));gap:8px;display:grid}.strip-item{color:#344054;background:var(--panel);border:1px solid var(--line);border-radius:8px;align-items:center;gap:8px;min-height:38px;padding:0 10px;display:flex}.page-frame{gap:14px;padding:14px;display:grid}.page-title-row{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:14px;padding-bottom:12px;display:flex}.query-panel{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;padding:10px}.toolbar{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.message-query input{width:150px}.message-selector-grid{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;margin-bottom:10px;display:grid}.message-selector-grid.single{grid-template-columns:minmax(320px,620px)}.entity-picker{border:1px solid var(--line);background:#fff;border-radius:8px;gap:8px;min-width:0;padding:10px;display:grid}.picker-head{color:#344054;justify-content:space-between;align-items:center;gap:8px;min-height:24px;font-weight:800;display:flex}.selected-entity{color:#0f3f38;background:#eef8f5;border:1px solid #b9dcd3;border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:8px;min-height:40px;padding:7px 9px;display:grid}.selected-entity strong,.selected-entity span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.selected-entity div{gap:2px;min-width:0;display:grid}.selected-entity div span{color:#52606d;font-size:11px}.picker-search{background:var(--panel-subtle);border:1px solid var(--line-strong);border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;height:34px;padding:0 6px 0 9px;display:grid}.picker-search input{width:100%;height:30px;box-shadow:none;background:0 0;border:0;padding:0}.picker-results{border:1px solid var(--line);border-radius:7px;max-height:236px;display:grid;overflow:auto}.picker-row{min-height:36px;color:var(--text);border:0;border-bottom:1px solid var(--line);cursor:pointer;text-align:left;background:#fff;grid-template-columns:96px minmax(120px,1fr) minmax(120px,1fr) auto;align-items:center;gap:8px;padding:6px 8px;display:grid}.picker-row:last-child{border-bottom:0}.picker-row:hover,.picker-row.selected{background:#f3f8f6}.picker-row strong,.picker-row span{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.picker-empty,.picker-error{color:var(--muted);text-align:center;padding:9px}.picker-error{color:var(--danger);background:#fff2f0;border:1px solid #efb4ad;border-radius:7px}input,textarea{color:var(--text);border:1px solid var(--line-strong);background:#fff;border-radius:7px;outline:none}input{width:190px;height:34px;padding:0 10px}textarea{resize:vertical;width:100%;padding:9px 10px}input:focus,textarea:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--focus)}.small-input{width:88px}.field-inline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.field-inline span{font-size:11px;font-weight:700}.searchbox{border:1px solid var(--line-strong);background:#fff;border-radius:7px;align-items:center;gap:8px;width:min(380px,100%);height:34px;padding:0 10px;display:inline-flex}.searchbox input{width:100%;height:30px;box-shadow:none;border:0;padding:0}.btn{color:#1d2939;border:1px solid var(--line-strong);cursor:pointer;white-space:nowrap;background:#fff;border-radius:7px;justify-content:center;align-items:center;gap:6px;min-height:34px;padding:0 12px;display:inline-flex}.btn:hover:not(:disabled){background:#f7f9fb}.btn:disabled{color:var(--muted-2);cursor:not-allowed}.btn.primary{color:#fff;background:var(--brand);border-color:var(--brand)}.btn.primary:hover:not(:disabled){background:#12594f}.btn.ghost{background:var(--panel-subtle)}.btn.danger{color:var(--danger);background:#fff7f5;border-color:#efb4ad}.btn.danger:hover:not(:disabled){background:#ffeceb}.btn.warn{color:var(--warn);background:#fff8ec;border-color:#e7c77e}.btn.warn:hover:not(:disabled){background:#fff1d6}.btn:disabled,.btn.primary:disabled,.btn.warn:disabled,.btn.danger:disabled{color:var(--muted-2);border-color:var(--line);cursor:not-allowed;background:#f3f5f7}.btn.full{width:100%}.icon-text{gap:7px}.compact-btn{min-height:28px;padding:0 8px;font-size:12px}.row-link,.link-button{color:var(--brand-2);cursor:pointer;background:0 0;border:0;align-items:center;gap:4px;padding:0;display:inline-flex}.table-wrap{border:1px solid var(--line);border-radius:8px;width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;font-size:12.5px}.data-table th,.data-table td{border-bottom:1px solid var(--line);text-align:left;vertical-align:middle;white-space:nowrap;height:38px;padding:7px 9px}.data-table th{z-index:0;color:#475467;background:var(--panel-strong);font-weight:800;position:sticky;top:0}.data-table tbody tr:hover{background:#fbfcfd}.data-table tr:last-child td{border-bottom:0}.mono{font-family:SFMono-Regular,Consolas,Liberation Mono,monospace}.truncate{text-overflow:ellipsis;max-width:380px;overflow:hidden}.badge{color:#4f5b68;white-space:nowrap;background:#f3f6f8;border:1px solid #d7e0e8;border-radius:999px;align-items:center;min-height:22px;padding:1px 8px;display:inline-flex}.badge.good{color:var(--good);background:#eef8f2;border-color:#b9dcc7}.badge.danger{color:var(--danger);background:#fff2f0;border-color:#efb4ad}.badge.warn{color:var(--warn);background:#fff8e7;border-color:#e7c77e}.empty-cell{color:var(--muted);text-align:center}.split-layout{grid-template-columns:minmax(0,1fr) 330px;align-items:start;gap:14px;display:grid}.split-main,.split-side{min-width:0}.entity-head{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;justify-content:space-between;align-items:flex-start;gap:14px;padding:14px;display:flex}.entity-title{font-size:20px;font-weight:800;line-height:1.25}.entity-subtitle{color:var(--muted);margin-top:4px}.summary-grid{grid-template-columns:repeat(4,minmax(150px,1fr));gap:8px;display:grid}.about-text{color:#344054;border:1px solid var(--line);background:#fbfcfd;border-radius:8px;margin:0;padding:10px}.section-block,.action-dock,.surface{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-width:0;padding:12px}.section-head{justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px;display:flex}.section-head p{color:var(--muted);margin:5px 0 0}.action-dock{gap:10px;display:grid;position:sticky;top:82px}.dock-title{color:#344054;border-bottom:1px solid var(--line);padding-bottom:4px;font-weight:800}.action-dock>.btn,.action-dock .action-stack .btn{justify-content:center;width:100%}.duration-field{gap:4px;display:grid}.duration-field span{color:var(--muted);font-size:11px;font-weight:800}.duration-field input{width:100%}.action-stack{gap:10px;display:grid}.action-stack .btn,.action-dock>.btn{min-height:42px}.danger-zone{border-top:1px solid var(--line);flex-wrap:wrap;gap:8px;margin-top:10px;padding-top:10px;display:flex}.authorization-block{gap:10px;display:grid}.authorization-table{table-layout:fixed;min-width:720px}.authorization-table th,.authorization-table td{height:46px}.device-text{text-overflow:ellipsis;max-width:260px;overflow:hidden}.device-actions-head{width:190px}.device-actions-cell{width:190px;min-width:190px}.device-actions{white-space:normal;grid-template-columns:repeat(2,minmax(82px,1fr));gap:6px;min-width:178px;display:grid}.device-actions .btn{justify-content:center;width:100%}.operation-row{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;display:grid}.operation-box{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;flex-wrap:wrap;align-items:center;gap:8px;padding:10px;display:flex}.operation-title{align-items:center;gap:6px;width:100%;font-weight:800;display:flex}.checkline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.checkline input{width:auto;height:auto}.alert{color:#8a251d;background:#fff2f0;border:1px solid #efb4ad;border-radius:8px;align-items:flex-start;gap:8px;padding:9px 10px;display:flex}.json-block{color:#d8e6f0;background:#141a22;border:1px solid #2a3542;border-radius:8px;max-height:520px;margin:0;padding:12px;font-size:12px;overflow:auto}.raw-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.loading-line{min-height:80px;color:var(--muted);place-items:center;display:grid}.empty-panel{min-height:92px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;place-items:center;display:grid}.gift-metrics .metric{background:linear-gradient(145deg,#fff,#f6f9f9);min-height:68px;padding:12px}.gift-metrics .metric strong{font-size:17px}.gift-file-icon{color:var(--brand);background:#eaf6f3;border:1px solid #c7e3dc;flex:none;place-items:center;display:grid}.gift-format-chips{flex-wrap:wrap;flex:none;justify-content:flex-end;gap:6px;display:flex}.gift-format-chips span{color:#33645d;letter-spacing:.02em;background:#eef8f5;border:1px solid #cfe5df;border-radius:999px;padding:4px 8px;font-size:10px;font-weight:800}.gift-list-summary{color:var(--muted);margin-left:auto;font-size:11px;font-weight:700}.gift-import-modal{width:min(860px,100%)}.gift-import-modal-body{gap:14px}.gift-import-note{color:var(--muted);justify-content:space-between;align-items:center;gap:12px;line-height:1.45;display:flex}.gift-file-picker{min-height:78px;color:var(--text);cursor:pointer;background:#fff;border:1px dashed #b7ccc8;border-radius:10px;grid-template-columns:42px minmax(0,1fr) auto;align-items:center;gap:12px;padding:12px 14px;transition:border-color .16s,background .16s,box-shadow .16s;display:grid;position:relative}.gift-file-picker:hover,.gift-file-picker.has-file{border-color:var(--brand);background:#f8fcfb;box-shadow:0 0 0 2px #176d610d}.gift-file-picker input{opacity:0;pointer-events:none;width:1px;height:1px;position:absolute}.gift-file-icon{border-radius:9px;width:40px;height:40px}.gift-file-copy{gap:2px;min-width:0;display:grid}.gift-field-label{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-size:10px;font-weight:800}.gift-file-copy strong{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.gift-file-copy small{color:var(--muted);font-size:11px;font-weight:500}.gift-file-action{color:var(--brand);background:#f0f8f6;border:1px solid #c7e3dc;border-radius:7px;padding:7px 10px;font-size:11px;font-weight:800}.gift-fields-grid{grid-template-columns:minmax(200px,1.5fr) repeat(3,minmax(120px,1fr));gap:10px;display:grid}.gift-fields-grid label,.gift-reason-field{color:var(--muted);gap:6px;font-size:11px;font-weight:700;display:grid}.gift-fields-grid input,.gift-reason-field input{min-width:0;height:38px;color:var(--text);border:1px solid var(--line);background:#fff;border-radius:7px;padding:0 10px}.gift-fields-grid input:focus,.gift-reason-field input:focus{border-color:#77b6aa;outline:none;box-shadow:0 0 0 3px #176d6114}.gift-switch{color:#344054;cursor:pointer;align-items:center;gap:9px;font-size:12px;font-weight:700;display:inline-flex}.gift-switch input{opacity:0;width:1px;height:1px;position:absolute}.gift-switch-track{background:#c8d0d5;border-radius:999px;align-items:center;width:34px;height:19px;padding:2px;transition:background .16s;display:flex}.gift-switch-track span{background:#fff;border-radius:50%;width:15px;height:15px;transition:transform .16s;box-shadow:0 1px 3px #10182838}.gift-switch input:checked+.gift-switch-track{background:var(--brand)}.gift-switch input:checked+.gift-switch-track span{transform:translate(15px)}.gift-switch input:focus-visible+.gift-switch-track{outline-offset:2px;outline:3px solid #176d6129}.gift-validation{color:#d5fff5;background:#173631;border:1px solid #24564e;border-radius:9px;overflow:hidden}.gift-validation-head{color:#e3fff9;background:#ffffff09;border-bottom:1px solid #ffffff17;align-items:center;gap:9px;padding:10px 12px;display:flex}.gift-validation-head div{gap:2px;display:grid}.gift-validation-head span{color:#99cfc4;font-size:10px}.gift-validation pre{color:#d5fff5;max-height:180px;margin:0;padding:11px 12px;font-size:11px;overflow:auto}.gift-animation-shell{background:radial-gradient(circle,#f9f3ff,#eef8f5);place-items:center;min-height:210px;display:grid;position:relative}.gift-animation{width:200px;height:200px}.gift-animation canvas{width:100%!important;height:100%!important}.gift-play{width:30px;height:30px;color:var(--text);border:1px solid var(--line);background:#ffffffe6;border-radius:50%;place-items:center;display:grid;position:absolute;bottom:8px;right:8px}.gift-table-wrap{background:#fff}.gift-table{min-width:1080px}.gift-table th:first-child{width:74px}.gift-table td{vertical-align:middle}.gift-animation-shell.compact{border:1px solid var(--line);border-radius:9px;width:56px;min-height:56px;overflow:hidden}.gift-animation-shell.compact .gift-animation{width:54px;height:54px}.gift-animation-shell.compact .gift-play{width:20px;height:20px;bottom:3px;right:3px}.gift-row-disabled{opacity:.68}.gift-table-title,.gift-sort-order,.gift-source-size,.gift-convert-price{display:block}.gift-table-title{text-overflow:ellipsis;white-space:nowrap;max-width:220px;overflow:hidden}.gift-sort-order,.gift-source-size,.gift-convert-price{color:var(--muted);margin-top:3px;font-size:10px}.gift-table-price{color:#755b00}.gift-table-actions{align-items:center;gap:6px;display:flex}.collectible-button{color:#6548a8;background:#f7f3ff;border-color:#ddd2f5}.collectible-button:hover{background:#efe8ff;border-color:#cbbaf0}.collectible-modal{width:min(1180px,100%);max-height:min(92vh,980px)}.collectible-modal .modal-head p{color:var(--muted);margin:4px 0 0;font-size:11px}.collectible-modal-body{background:#f5f7fa;gap:16px;padding:16px 18px 22px;overflow:auto}.collectible-loading{min-height:90px;color:var(--muted);justify-content:center;align-items:center;gap:8px;display:flex}.collectible-empty{color:#66568c;background:linear-gradient(135deg,#fbf9ff,#f2f7ff);border:1px dashed #cfc3e9;border-radius:12px;align-items:center;gap:12px;padding:16px;display:flex}.collectible-empty div,.collectible-definition-head>div:first-child,.collectible-section-head>div:first-child{gap:3px;display:grid}.collectible-empty span,.collectible-definition-head span,.collectible-section-head span{color:var(--muted);font-size:10px;font-weight:500}.collectible-active{background:#fff;border:1px solid #ddd6ee;border-radius:12px;overflow:hidden;box-shadow:0 5px 16px #422e6e0d}.collectible-active-head{background:linear-gradient(100deg,#fbf9ff,#f4f9ff);border-bottom:1px solid #e9e4f3;justify-content:space-between;align-items:center;gap:12px;padding:12px 14px;display:flex}.collectible-active-head>div{color:#60458f;align-items:center;gap:9px;display:flex}.collectible-active-head>div>div{gap:2px;display:grid}.collectible-active-head span{color:var(--muted);font-size:10px}.collectible-active-grid{background:var(--line);grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:1px;display:grid}.collectible-active-grid article{background:#fff;align-items:center;gap:9px;min-width:0;padding:9px 11px;display:flex}.collectible-active-grid article>div:last-child{gap:2px;min-width:0;display:grid}.collectible-active-grid article strong{text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.collectible-active-grid article span{color:var(--muted);font-size:9px}.collectible-definition{border:1px solid var(--line);background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px #1018280a}.collectible-definition-head{border-bottom:1px solid var(--line);background:linear-gradient(110deg,#f8fbfa,#fbf9ff);justify-content:space-between;align-items:center;gap:12px;padding:14px 16px;display:flex}.collectible-main-fields{border-bottom:1px solid var(--line);background:#fbfcfd;padding:14px 16px}.collectible-section{border-bottom:1px solid var(--line);padding:14px 16px}.collectible-section:last-child{border-bottom:0}.collectible-section-head{justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;display:flex}.collectible-section-tools{align-items:center;gap:7px;display:flex}.collectible-rows{gap:7px;display:grid}.collectible-row{background:#fafbfc;border:1px solid #e1e6eb;border-radius:9px;align-items:end;gap:7px;padding:9px 9px 9px 36px;display:grid;position:relative}.collectible-row:hover{background:#fff;border-color:#cbd7dd;box-shadow:0 3px 10px #10182809}.collectible-row.animated{grid-template-columns:minmax(120px,1.2fr) 90px 78px minmax(160px,1.4fr) 48px 30px}.collectible-row.backdrop{grid-template-columns:minmax(110px,1.2fr) 70px 80px 70px repeat(4,52px) 48px 30px}.collectible-row-index{color:#71668c;background:#f0edf7;border-right:1px solid #e0d9ed;border-radius:8px 0 0 8px;place-items:center;width:27px;font-size:10px;font-weight:800;display:grid;position:absolute;top:0;bottom:0;left:0}.collectible-row label{gap:4px;min-width:0;display:grid}.collectible-row label>span{color:var(--muted);text-transform:uppercase;letter-spacing:.025em;font-size:9px;font-weight:800}.collectible-row input:not([type=file]){width:100%;min-width:0;height:32px;color:var(--text);font:inherit;background:#fff;border:1px solid #d5dde3;border-radius:7px;padding:0 8px;font-size:11px}.collectible-row input:focus{border-color:#8d7aba;outline:none;box-shadow:0 0 0 3px #6f5bae14}.collectible-file input{opacity:0;pointer-events:none;width:1px;height:1px;position:absolute}.collectible-file em{color:#625080;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;background:#f7f4fd;border:1px dashed #cfc4e1;border-radius:7px;align-items:center;gap:5px;min-width:0;height:32px;padding:0 8px;font-size:10px;font-style:normal;font-weight:700;display:flex;overflow:hidden}.collectible-inline-preview{color:#8c7cae;background:radial-gradient(circle,#fff,#eee8f8);border:1px solid #ded5ed;border-radius:8px;place-items:center;width:42px;height:42px;display:grid;overflow:hidden}.collectible-animation{width:100%;height:100%;overflow:hidden}.collectible-animation.compact{background:radial-gradient(circle,#fff,#f0ebfa);border:1px solid #e0d9ec;border-radius:8px;flex:0 0 42px;place-items:center;width:42px;height:42px;display:grid}.collectible-animation canvas{width:100%!important;height:100%!important}.collectible-animation.failed{color:#b42318;background:#fff4f2}.collectible-animation.loading{color:#807397}.collectible-file-error{color:#b42318;grid-column:1/-1;font-size:10px}.collectible-color input{cursor:pointer;height:32px!important;padding:3px!important}.collectible-backdrop-preview{border:1px solid #2a1f472e;border-radius:8px;flex:0 0 42px;place-items:center;width:42px;height:42px;font-size:11px;font-weight:900;display:grid;box-shadow:inset 0 0 0 1px #fff3}.collectible-row .icon-btn{align-self:center}.collectible-row .icon-btn:disabled{opacity:.28}@media (width<=900px){.gift-fields-grid,.collectible-row.animated,.collectible-row.backdrop{grid-template-columns:repeat(2,minmax(0,1fr))}.collectible-inline-preview,.collectible-backdrop-preview,.collectible-row .icon-btn{place-self:center start}}@media (width<=620px){.gift-import-note{flex-direction:column;align-items:flex-start}.gift-format-chips{justify-content:flex-start}.gift-file-picker{grid-template-columns:40px minmax(0,1fr)}.gift-file-action{display:none}.gift-fields-grid{grid-template-columns:1fr}.gift-list-summary{width:100%;margin-left:0}.collectible-modal-body{padding:10px}.collectible-definition-head,.collectible-section-head{flex-direction:column;align-items:flex-start}.collectible-row.animated,.collectible-row.backdrop{grid-template-columns:1fr}.collectible-active-grid{grid-template-columns:1fr 1fr}}.modal-backdrop{z-index:10000;background:#11182785;place-items:center;padding:24px;display:grid;position:fixed;inset:0}.modal{border:1px solid var(--line);width:min(760px,100%);max-height:min(820px,100vh - 48px);box-shadow:var(--shadow);background:#fff;border-radius:8px;padding:0;overflow:hidden}.command-modal{flex-direction:column;display:flex}.command-modal>.modal-head,.command-modal>.modal-actions{flex:none}.modal-head{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:12px;padding:16px 18px 12px;display:flex}.icon-btn{background:var(--panel-subtle);border:1px solid var(--line);cursor:pointer;border-radius:7px;place-items:center;width:30px;height:30px;display:grid}.command-steps{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;display:grid}.command-body{grid-auto-rows:max-content;gap:12px;min-height:0;padding:14px 18px;display:grid;overflow:auto}.command-step{min-height:38px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;align-items:center;gap:8px;padding:0 10px;display:flex}.command-step span{border:1px solid var(--line);background:#fff;border-radius:999px;place-items:center;width:20px;height:20px;font-size:11px;font-weight:800;display:grid}.command-step.active{color:var(--brand);border-color:#a9d8ce}.command-step.done{color:var(--good);border-color:#b9dcc7}.form-field{gap:6px;display:grid}.form-field span,.form-stack span{color:#4b5563;font-weight:800}.command-preview{gap:8px;display:grid}.command-preview .json-block{max-height:150px}.preview-head,.result-title{color:#344054;align-items:center;gap:7px;font-weight:800;display:flex}.result-box{border:1px solid var(--line);background:#fbfcfd;border-radius:8px;gap:8px;padding:10px;display:grid}.result-line{grid-template-columns:92px minmax(0,1fr);gap:8px;display:grid}.result-line span{color:var(--muted)}.result-line strong{overflow-wrap:anywhere}.result-message{color:#344054}.modal-actions{border-top:1px solid var(--line);background:#fff;justify-content:flex-end;padding:12px 18px}.login-page{background:var(--bg);place-items:center;min-height:100vh;padding:24px;display:grid}.login-panel{border:1px solid var(--line);width:min(420px,100%);box-shadow:var(--shadow);background:#fff;border-radius:8px;gap:18px;padding:22px;display:grid}.login-head{justify-content:space-between;align-items:center;gap:12px;display:flex}.login-head-actions{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px;display:flex}.login-chip{min-height:24px;color:var(--brand);background:#edf7f4;border:1px solid #c9e2dc;border-radius:999px;align-items:center;padding:0 8px;font-size:12px;display:inline-flex}.login-copy h1{margin:0;font-size:22px}.login-copy p{color:var(--muted);margin:8px 0 0}.form-stack{gap:12px;display:grid}.form-stack label{gap:6px;display:grid}.form-stack input{width:100%}.boot-screen{align-content:center;place-items:center;gap:18px;min-height:100vh;display:grid}.loader-bar{background:#d7dde4;border-radius:999px;width:180px;height:4px;overflow:hidden}.loader-bar:before{content:"";background:var(--brand);width:42%;height:100%;animation:1s ease-in-out infinite load;display:block}.spin{animation:.8s linear infinite spin}@keyframes load{0%{transform:translate(-120%)}to{transform:translate(260%)}}@keyframes spin{to{transform:rotate(360deg)}}@media (width<=1120px){.shell{grid-template-columns:1fr}.sidebar{height:auto;position:static}.nav-list{grid-template-columns:repeat(4,minmax(0,1fr))}.sidebar-status{display:none}.overview-band,.split-layout,.operation-row,.raw-grid,.message-selector-grid,.message-selector-grid.single{grid-template-columns:1fr}.action-dock{position:static}}@media (width<=760px){.content,.topbar{padding-left:14px;padding-right:14px}.command-grid,.work-strip,.overview-metrics,.metric-row,.summary-grid,.command-steps{grid-template-columns:1fr}.sidebar{gap:12px;padding:14px}.nav-list{grid-template-columns:repeat(2,minmax(0,1fr))}.topbar,.page-title-row,.entity-head{flex-direction:column;align-items:flex-start}input,.searchbox{width:100%}.toolbar{align-items:stretch}.picker-row,.selected-entity{grid-template-columns:1fr}} diff --git a/cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css b/cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css new file mode 100644 index 00000000..f38bf9a5 --- /dev/null +++ b/cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css @@ -0,0 +1 @@ +:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:#f3f5f7;--panel:#fff;--panel-subtle:#f8fafb;--panel-strong:#eef2f5;--line:#d9e1e8;--line-strong:#c2ccd6;--text:#101828;--muted:#667085;--muted-2:#98a2b3;--brand:#176d61;--brand-2:#245b9d;--good:#167447;--warn:#a15c07;--danger:#b42318;--sidebar:#11161d;--sidebar-soft:#1b222b;--sidebar-line:#2c3541;--focus:#176d6129;--shadow:0 18px 52px #10182824}*{box-sizing:border-box}html,body,#root{min-height:100%}body{color:var(--text);background:var(--bg);margin:0;font:13px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}button,input,textarea{font:inherit}a{color:inherit;text-decoration:none}.shell{grid-template-columns:232px minmax(0,1fr);min-height:100vh;display:grid}.sidebar{color:#eef2f6;background:var(--sidebar);border-right:1px solid var(--sidebar-line);flex-direction:column;gap:16px;height:100vh;padding:18px 12px;display:flex;position:sticky;top:0;overflow-y:auto}.brand{align-items:center;gap:10px;min-height:42px;padding:0 4px;display:flex}.brand.compact{justify-content:center}.brand-elevated .brand-mark{box-shadow:0 8px 24px #176d6142}.brand-mark{color:#fff;background:var(--brand);border:1px solid #fff3;border-radius:8px;place-items:center;width:34px;height:34px;font-weight:800;display:grid}.brand strong{font-size:14px;line-height:1.1;display:block}.brand small{color:#aeb8c4;margin-top:3px;font-size:11px;display:block}.sidebar-label{color:#8492a6;text-transform:uppercase;padding:0 8px;font-size:11px;font-weight:700}.nav-list,.nav-section{gap:4px;display:grid}.nav-section-toggle{color:#8fa0b4;cursor:pointer;text-align:left;background:0 0;border:1px solid #0000;border-radius:7px;grid-template-columns:18px minmax(0,1fr) 16px;align-items:center;gap:9px;width:100%;min-height:38px;padding:0 10px;font-size:12px;font-weight:800;display:grid}.nav-section-toggle:hover,.nav-section.active .nav-section-toggle{color:#fff;background:var(--sidebar-soft);border-color:#34404d}.nav-section-chevron{color:#8fa0b4;justify-self:end;transition:transform .14s}.nav-section.open .nav-section-chevron{transform:rotate(180deg)}.nav-children{gap:4px;padding:2px 0 2px 18px;display:grid}.nav-item{color:#c6d0dc;border:1px solid #0000;border-radius:7px;grid-template-columns:18px minmax(0,1fr);align-items:center;gap:9px;min-height:38px;padding:0 10px;display:grid}.nav-dot{background:#687789;border-radius:999px;justify-self:center;width:6px;height:6px}.nav-item:hover,.nav-item.active{color:#fff;background:var(--sidebar-soft);border-color:#34404d}.nav-item.active .nav-dot{background:var(--brand)}.sidebar-status{gap:7px;margin-top:auto;display:grid}.runtime-row{color:#cbd5df;background:#171d25;border:1px solid #27313c;border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;min-height:32px;padding:0 8px;display:grid}.runtime-row strong{color:#fff;font-size:11px}.workspace{min-width:0}.topbar{z-index:20;border-bottom:1px solid var(--line);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);background:#fffffff0;justify-content:space-between;align-items:center;gap:18px;min-height:66px;padding:12px 24px;display:flex;position:sticky;top:0}.topbar h1{margin:2px 0 0;font-size:20px;line-height:1.2}.topbar-actions,.page-actions,.section-action,.entity-badges,.row-actions,.modal-actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.language-switch{background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;align-items:center;min-height:30px;padding:2px;display:inline-flex}.language-switch button{min-width:42px;min-height:24px;color:var(--muted);cursor:pointer;background:0 0;border:0;border-radius:999px;padding:0 9px;font-weight:800}.language-switch button.active{color:#fff;background:var(--brand)}.language-switch button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}.actor-pill{color:#344054;background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;align-items:center;min-height:30px;padding:0 10px;display:inline-flex}.content{gap:16px;padding:18px 24px 30px;display:grid}.eyebrow{color:var(--muted);text-transform:uppercase;font-size:11px;font-weight:800}.dashboard-layout,.stacked-sections{gap:14px;display:grid}.overview-band,.page-frame{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-width:0}.overview-band{grid-template-columns:minmax(220px,1fr) minmax(420px,.9fr);align-items:center;gap:16px;padding:16px;display:grid}.overview-band h2,.page-title-row h2,.section-head h2,.modal h2{margin:0;font-size:18px;line-height:1.25}.overview-metrics,.metric-row{grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;display:grid}.overview-metrics{grid-template-columns:repeat(3,minmax(120px,1fr))}.status-item,.metric,.summary-item{background:var(--panel-subtle);border:1px solid var(--line);border-radius:7px;min-width:0;padding:10px}.status-item span,.metric span,.summary-item span{color:var(--muted);margin-bottom:6px;font-size:11px;display:block}.status-item strong,.metric strong,.summary-item strong{overflow-wrap:anywhere;color:var(--text);font-weight:800;display:block}.status-item.good,.metric.good{border-color:#afd8bf}.status-item.warn,.metric.warn{border-color:#e7c77e}.metric.danger{border-color:#efb4ad}.command-grid{grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px;display:grid}.launcher{background:var(--panel);border:1px solid var(--line);border-radius:8px;grid-template-columns:38px minmax(0,1fr) 18px;align-items:center;gap:12px;min-height:94px;padding:14px;display:grid}.launcher:hover{border-color:var(--brand)}.launcher-icon{width:38px;height:38px;color:var(--brand);background:#edf7f4;border:1px solid #c9e2dc;border-radius:8px;place-items:center;display:grid}.launcher-copy{gap:4px;display:grid}.launcher-copy strong{font-size:15px}.launcher-copy span{color:var(--muted)}.work-strip{grid-template-columns:repeat(4,minmax(160px,1fr));gap:8px;display:grid}.strip-item{color:#344054;background:var(--panel);border:1px solid var(--line);border-radius:8px;align-items:center;gap:8px;min-height:38px;padding:0 10px;display:flex}.page-frame{gap:14px;padding:14px;display:grid}.page-title-row{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:14px;padding-bottom:12px;display:flex}.query-panel{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;padding:10px}.toolbar{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.message-query input{width:150px}.message-selector-grid{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;margin-bottom:10px;display:grid}.message-selector-grid.single{grid-template-columns:minmax(320px,620px)}.entity-picker{border:1px solid var(--line);background:#fff;border-radius:8px;gap:8px;min-width:0;padding:10px;display:grid}.picker-head{color:#344054;justify-content:space-between;align-items:center;gap:8px;min-height:24px;font-weight:800;display:flex}.selected-entity{color:#0f3f38;background:#eef8f5;border:1px solid #b9dcd3;border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:8px;min-height:40px;padding:7px 9px;display:grid}.selected-entity strong,.selected-entity span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.selected-entity div{gap:2px;min-width:0;display:grid}.selected-entity div span{color:#52606d;font-size:11px}.picker-search{background:var(--panel-subtle);border:1px solid var(--line-strong);border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;height:34px;padding:0 6px 0 9px;display:grid}.picker-search input{width:100%;height:30px;box-shadow:none;background:0 0;border:0;padding:0}.picker-results{border:1px solid var(--line);border-radius:7px;max-height:236px;display:grid;overflow:auto}.picker-row{min-height:36px;color:var(--text);border:0;border-bottom:1px solid var(--line);cursor:pointer;text-align:left;background:#fff;grid-template-columns:96px minmax(120px,1fr) minmax(120px,1fr) auto;align-items:center;gap:8px;padding:6px 8px;display:grid}.picker-row:last-child{border-bottom:0}.picker-row:hover,.picker-row.selected{background:#f3f8f6}.picker-row strong,.picker-row span{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.picker-empty,.picker-error{color:var(--muted);text-align:center;padding:9px}.picker-error{color:var(--danger);background:#fff2f0;border:1px solid #efb4ad;border-radius:7px}input,textarea{color:var(--text);border:1px solid var(--line-strong);background:#fff;border-radius:7px;outline:none}input{width:190px;height:34px;padding:0 10px}textarea{resize:vertical;width:100%;padding:9px 10px}input:focus,textarea:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--focus)}.small-input{width:88px}.field-inline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.field-inline span{font-size:11px;font-weight:700}.searchbox{border:1px solid var(--line-strong);background:#fff;border-radius:7px;align-items:center;gap:8px;width:min(380px,100%);height:34px;padding:0 10px;display:inline-flex}.searchbox input{width:100%;height:30px;box-shadow:none;border:0;padding:0}.btn{color:#1d2939;border:1px solid var(--line-strong);cursor:pointer;white-space:nowrap;background:#fff;border-radius:7px;justify-content:center;align-items:center;gap:6px;min-height:34px;padding:0 12px;display:inline-flex}.btn:hover:not(:disabled){background:#f7f9fb}.btn:disabled{color:var(--muted-2);cursor:not-allowed}.btn.primary{color:#fff;background:var(--brand);border-color:var(--brand)}.btn.primary:hover:not(:disabled){background:#12594f}.btn.ghost{background:var(--panel-subtle)}.btn.danger{color:var(--danger);background:#fff7f5;border-color:#efb4ad}.btn.danger:hover:not(:disabled){background:#ffeceb}.btn.warn{color:var(--warn);background:#fff8ec;border-color:#e7c77e}.btn.warn:hover:not(:disabled){background:#fff1d6}.btn:disabled,.btn.primary:disabled,.btn.warn:disabled,.btn.danger:disabled{color:var(--muted-2);border-color:var(--line);cursor:not-allowed;background:#f3f5f7}.btn.full{width:100%}.icon-text{gap:7px}.compact-btn{min-height:28px;padding:0 8px;font-size:12px}.row-link,.link-button{color:var(--brand-2);cursor:pointer;background:0 0;border:0;align-items:center;gap:4px;padding:0;display:inline-flex}.table-wrap{border:1px solid var(--line);border-radius:8px;width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;font-size:12.5px}.data-table th,.data-table td{border-bottom:1px solid var(--line);text-align:left;vertical-align:middle;white-space:nowrap;height:38px;padding:7px 9px}.data-table th{z-index:0;color:#475467;background:var(--panel-strong);font-weight:800;position:sticky;top:0}.data-table tbody tr:hover{background:#fbfcfd}.data-table tr:last-child td{border-bottom:0}.mono{font-family:SFMono-Regular,Consolas,Liberation Mono,monospace}.truncate{text-overflow:ellipsis;max-width:380px;overflow:hidden}.badge{color:#4f5b68;white-space:nowrap;background:#f3f6f8;border:1px solid #d7e0e8;border-radius:999px;align-items:center;min-height:22px;padding:1px 8px;display:inline-flex}.badge.good{color:var(--good);background:#eef8f2;border-color:#b9dcc7}.badge.danger{color:var(--danger);background:#fff2f0;border-color:#efb4ad}.badge.warn{color:var(--warn);background:#fff8e7;border-color:#e7c77e}.empty-cell{color:var(--muted);text-align:center}.split-layout{grid-template-columns:minmax(0,1fr) 330px;align-items:start;gap:14px;display:grid}.split-main,.split-side{min-width:0}.entity-head{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;justify-content:space-between;align-items:flex-start;gap:14px;padding:14px;display:flex}.entity-title{font-size:20px;font-weight:800;line-height:1.25}.entity-subtitle{color:var(--muted);margin-top:4px}.summary-grid{grid-template-columns:repeat(4,minmax(150px,1fr));gap:8px;display:grid}.about-text{color:#344054;border:1px solid var(--line);background:#fbfcfd;border-radius:8px;margin:0;padding:10px}.section-block,.action-dock,.surface{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-width:0;padding:12px}.section-head{justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px;display:flex}.section-head p{color:var(--muted);margin:5px 0 0}.action-dock{gap:10px;display:grid;position:sticky;top:82px}.dock-title{color:#344054;border-bottom:1px solid var(--line);padding-bottom:4px;font-weight:800}.action-dock>.btn,.action-dock .action-stack .btn{justify-content:center;width:100%}.duration-field{gap:4px;display:grid}.duration-field span{color:var(--muted);font-size:11px;font-weight:800}.duration-field input{width:100%}.action-stack{gap:10px;display:grid}.action-stack .btn,.action-dock>.btn{min-height:42px}.danger-zone{border-top:1px solid var(--line);flex-wrap:wrap;gap:8px;margin-top:10px;padding-top:10px;display:flex}.authorization-block{gap:10px;display:grid}.authorization-table{table-layout:fixed;min-width:720px}.authorization-table th,.authorization-table td{height:46px}.device-text{text-overflow:ellipsis;max-width:260px;overflow:hidden}.device-actions-head{width:190px}.device-actions-cell{width:190px;min-width:190px}.device-actions{white-space:normal;grid-template-columns:repeat(2,minmax(82px,1fr));gap:6px;min-width:178px;display:grid}.device-actions .btn{justify-content:center;width:100%}.operation-row{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;display:grid}.operation-box{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;flex-wrap:wrap;align-items:center;gap:8px;padding:10px;display:flex}.operation-title{align-items:center;gap:6px;width:100%;font-weight:800;display:flex}.checkline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.checkline input{width:auto;height:auto}.alert{color:#8a251d;background:#fff2f0;border:1px solid #efb4ad;border-radius:8px;align-items:flex-start;gap:8px;padding:9px 10px;display:flex}.json-block{color:#d8e6f0;background:#141a22;border:1px solid #2a3542;border-radius:8px;max-height:520px;margin:0;padding:12px;font-size:12px;overflow:auto}.raw-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.loading-line{min-height:80px;color:var(--muted);place-items:center;display:grid}.empty-panel{min-height:92px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;place-items:center;display:grid}.gift-metrics .metric{background:linear-gradient(145deg,#fff,#f6f9f9);min-height:68px;padding:12px}.gift-metrics .metric strong{font-size:17px}.gift-file-icon{color:var(--brand);background:#eaf6f3;border:1px solid #c7e3dc;flex:none;place-items:center;display:grid}.gift-format-chips{flex-wrap:wrap;flex:none;justify-content:flex-end;gap:6px;display:flex}.gift-format-chips span{color:#33645d;letter-spacing:.02em;background:#eef8f5;border:1px solid #cfe5df;border-radius:999px;padding:4px 8px;font-size:10px;font-weight:800}.gift-list-summary{color:var(--muted);margin-left:auto;font-size:11px;font-weight:700}.gift-import-modal{width:min(860px,100%)}.gift-import-modal-body{gap:14px}.gift-source-tabs{gap:8px;display:flex}.official-gift-picker{gap:12px;min-width:0;display:grid}.official-gift-tools{align-items:center;gap:12px;display:flex}.official-gift-tools .searchbox{width:100%}.official-gift-tools>span{color:var(--muted);flex:none;font-size:11px;font-weight:750}.official-gift-categories{flex-wrap:wrap;gap:7px;display:flex}.official-gift-categories button{color:#49605c;min-height:32px;font:inherit;cursor:pointer;background:#f7faf9;border:1px solid #d7e2df;border-radius:999px;align-items:center;gap:7px;padding:5px 10px;font-size:11px;font-weight:800;transition:color .15s,background .15s,border-color .15s,box-shadow .15s;display:inline-flex}.official-gift-categories button:hover{color:var(--brand);border-color:#9fc9c0}.official-gift-categories button.active{color:#fff;background:var(--brand);border-color:var(--brand);box-shadow:0 4px 12px #176d612b}.official-gift-categories button span{min-width:20px;height:20px;color:inherit;background:#ffffffa6;border-radius:999px;place-items:center;padding:0 5px;font-size:10px;display:grid}.official-gift-categories button.active span{color:var(--brand)}.official-gift-list{border:1px solid var(--line);scrollbar-gutter:stable;background:#f6f9f8;border-radius:14px;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;min-height:126px;max-height:314px;padding:8px;display:grid;overflow:auto}.official-gift-option{text-align:left;min-width:0;color:var(--text);cursor:pointer;background:#fff;border:1px solid #dce6e3;border-radius:11px;gap:8px;padding:11px 12px;transition:border-color .15s,box-shadow .15s,transform .15s;display:grid;box-shadow:0 1px 2px #20363208}.official-gift-option:hover{border-color:#9fc9c0;transform:translateY(-1px);box-shadow:0 5px 14px #204c4414}.official-gift-option.selected{border-color:var(--brand);box-shadow:0 0 0 2px #176d611f,0 5px 14px #204c4414}.official-gift-option-head{grid-template-columns:minmax(0,1fr) auto;align-items:baseline;gap:8px;display:grid}.official-gift-option-head strong{text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.official-gift-option-head .mono{color:var(--muted);font-size:9px}.official-gift-option-meta{color:#667773;flex-wrap:wrap;gap:10px;font-size:10px;font-weight:700;display:flex}.official-gift-capabilities{flex-wrap:wrap;gap:5px;display:flex}.official-gift-capabilities>span{letter-spacing:.01em;border:1px solid #0000;border-radius:999px;padding:3px 7px;font-size:9px;font-weight:850}.official-gift-capabilities>span.yes{color:#136b4d;background:#e9f8f0;border-color:#bde6cf}.official-gift-capabilities>span.craft{color:#6e3ca0;background:#f3ebfb;border-color:#d9c5ef}.official-gift-capabilities>span.no{color:#78837f;background:#f1f3f2;border-color:#dde2e0}.official-gift-empty{min-height:108px;color:var(--muted);text-align:center;grid-column:1/-1;place-items:center;padding:20px;font-size:12px;display:grid}.official-gift-selected{border:1px solid var(--line);background:var(--surface-soft);border-radius:14px;grid-template-columns:108px minmax(0,1fr);align-items:center;gap:14px;padding:12px;display:grid}.official-gift-selected .gift-animation-shell{width:96px;height:96px}.official-gift-selected>div:last-child{gap:5px;min-width:0;display:grid}.official-gift-selected small{color:var(--muted)}.gift-import-note{color:var(--muted);justify-content:space-between;align-items:center;gap:12px;line-height:1.45;display:flex}.gift-file-picker{min-height:78px;color:var(--text);cursor:pointer;background:#fff;border:1px dashed #b7ccc8;border-radius:10px;grid-template-columns:42px minmax(0,1fr) auto;align-items:center;gap:12px;padding:12px 14px;transition:border-color .16s,background .16s,box-shadow .16s;display:grid;position:relative}.gift-file-picker:hover,.gift-file-picker.has-file{border-color:var(--brand);background:#f8fcfb;box-shadow:0 0 0 2px #176d610d}.gift-file-picker input{opacity:0;pointer-events:none;width:1px;height:1px;position:absolute}.gift-file-icon{border-radius:9px;width:40px;height:40px}.gift-file-copy{gap:2px;min-width:0;display:grid}.gift-field-label{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-size:10px;font-weight:800}.gift-file-copy strong{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.gift-file-copy small{color:var(--muted);font-size:11px;font-weight:500}.gift-file-action{color:var(--brand);background:#f0f8f6;border:1px solid #c7e3dc;border-radius:7px;padding:7px 10px;font-size:11px;font-weight:800}.gift-fields-grid{grid-template-columns:minmax(200px,1.5fr) repeat(3,minmax(120px,1fr));gap:10px;display:grid}.gift-fields-grid label,.gift-reason-field{color:var(--muted);gap:6px;font-size:11px;font-weight:700;display:grid}.gift-fields-grid input,.gift-reason-field input{width:100%;min-width:0;height:38px;color:var(--text);border:1px solid var(--line);background:#fff;border-radius:7px;padding:0 10px}.gift-fields-grid input:focus,.gift-reason-field input:focus{border-color:#77b6aa;outline:none;box-shadow:0 0 0 3px #176d6114}.gift-switch{color:#344054;cursor:pointer;align-items:center;gap:9px;font-size:12px;font-weight:700;display:inline-flex}.gift-switch input{opacity:0;width:1px;height:1px;position:absolute}.gift-switch-track{background:#c8d0d5;border-radius:999px;align-items:center;width:34px;height:19px;padding:2px;transition:background .16s;display:flex}.gift-switch-track span{background:#fff;border-radius:50%;width:15px;height:15px;transition:transform .16s;box-shadow:0 1px 3px #10182838}.gift-switch input:checked+.gift-switch-track{background:var(--brand)}.gift-switch input:checked+.gift-switch-track span{transform:translate(15px)}.gift-switch input:focus-visible+.gift-switch-track{outline-offset:2px;outline:3px solid #176d6129}.gift-validation{color:#d5fff5;background:#173631;border:1px solid #24564e;border-radius:9px;overflow:hidden}.gift-validation-head{color:#e3fff9;background:#ffffff09;border-bottom:1px solid #ffffff17;align-items:center;gap:9px;padding:10px 12px;display:flex}.gift-validation-head div{gap:2px;display:grid}.gift-validation-head span{color:#99cfc4;font-size:10px}.gift-validation pre{color:#d5fff5;max-height:180px;margin:0;padding:11px 12px;font-size:11px;overflow:auto}.gift-animation-shell{background:radial-gradient(circle,#f9f3ff,#eef8f5);place-items:center;min-height:210px;display:grid;position:relative}.gift-animation{width:200px;height:200px}.gift-animation canvas{width:100%!important;height:100%!important}.gift-play{width:30px;height:30px;color:var(--text);border:1px solid var(--line);background:#ffffffe6;border-radius:50%;place-items:center;display:grid;position:absolute;bottom:8px;right:8px}.gift-table-wrap{background:#fff}.gift-table{min-width:1080px}.gift-table th:first-child{width:74px}.gift-table td{vertical-align:middle}.gift-animation-shell.compact{border:1px solid var(--line);border-radius:9px;width:56px;min-height:56px;overflow:hidden}.gift-animation-shell.compact .gift-animation{width:54px;height:54px}.gift-animation-shell.compact .gift-play{width:20px;height:20px;bottom:3px;right:3px}.gift-row-disabled{opacity:.68}.gift-table-title,.gift-sort-order,.gift-source-size,.gift-convert-price{display:block}.gift-table-title{text-overflow:ellipsis;white-space:nowrap;max-width:220px;overflow:hidden}.gift-sort-order,.gift-source-size,.gift-convert-price{color:var(--muted);margin-top:3px;font-size:10px}.gift-table-price{color:#755b00}.gift-table-actions{align-items:center;gap:6px;display:flex}.collectible-button{color:#6548a8;background:#f7f3ff;border-color:#ddd2f5}.collectible-button:hover{background:#efe8ff;border-color:#cbbaf0}.collectible-modal{width:min(1180px,100%);max-height:min(92vh,980px)}.collectible-modal .modal-head p{color:var(--muted);margin:4px 0 0;font-size:11px}.collectible-modal-body{background:#f5f7fa;gap:16px;padding:16px 18px 22px;overflow:auto}.collectible-loading{min-height:90px;color:var(--muted);justify-content:center;align-items:center;gap:8px;display:flex}.collectible-empty{color:#66568c;background:linear-gradient(135deg,#fbf9ff,#f2f7ff);border:1px dashed #cfc3e9;border-radius:12px;align-items:center;gap:12px;padding:16px;display:flex}.collectible-empty div,.collectible-definition-head>div:first-child,.collectible-section-head>div:first-child{gap:3px;display:grid}.collectible-empty span,.collectible-definition-head span,.collectible-section-head span{color:var(--muted);font-size:10px;font-weight:500}.collectible-active{background:#fff;border:1px solid #ddd6ee;border-radius:12px;overflow:hidden;box-shadow:0 5px 16px #422e6e0d}.collectible-active-head{background:linear-gradient(100deg,#fbf9ff,#f4f9ff);border-bottom:1px solid #e9e4f3;justify-content:space-between;align-items:center;gap:12px;padding:12px 14px;display:flex}.collectible-active-head>div{color:#60458f;align-items:center;gap:9px;display:flex}.collectible-active-head>div>div{gap:2px;display:grid}.collectible-active-head span{color:var(--muted);font-size:10px}.collectible-active-grid{background:var(--line);grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:1px;display:grid}.collectible-active-grid article{background:#fff;align-items:center;gap:9px;min-width:0;padding:9px 11px;display:flex}.collectible-active-grid article>div:last-child{gap:2px;min-width:0;display:grid}.collectible-active-grid article strong{text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.collectible-active-grid article span{color:var(--muted);font-size:9px}.collectible-definition{border:1px solid var(--line);background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px #1018280a}.collectible-definition-head{border-bottom:1px solid var(--line);background:linear-gradient(110deg,#f8fbfa,#fbf9ff);justify-content:space-between;align-items:center;gap:12px;padding:14px 16px;display:flex}.collectible-main-fields{border-bottom:1px solid var(--line);background:#fbfcfd;padding:14px 16px}.collectible-section{border-bottom:1px solid var(--line);padding:14px 16px}.collectible-section:last-child{border-bottom:0}.collectible-section-head{justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;display:flex}.collectible-section-tools{align-items:center;gap:7px;display:flex}.collectible-rows{gap:7px;display:grid}.collectible-row{background:#fafbfc;border:1px solid #e1e6eb;border-radius:9px;align-items:end;gap:7px;padding:9px 9px 9px 36px;display:grid;position:relative}.collectible-row:hover{background:#fff;border-color:#cbd7dd;box-shadow:0 3px 10px #10182809}.collectible-row.animated{grid-template-columns:minmax(120px,1.2fr) 90px 78px minmax(160px,1.4fr) 48px 30px}.collectible-row.backdrop{grid-template-columns:minmax(110px,1.2fr) 70px 80px 70px repeat(4,52px) 48px 30px}.collectible-row-index{color:#71668c;background:#f0edf7;border-right:1px solid #e0d9ed;border-radius:8px 0 0 8px;place-items:center;width:27px;font-size:10px;font-weight:800;display:grid;position:absolute;top:0;bottom:0;left:0}.collectible-row label{gap:4px;min-width:0;display:grid}.collectible-row label>span{color:var(--muted);text-transform:uppercase;letter-spacing:.025em;font-size:9px;font-weight:800}.collectible-row input:not([type=file]){width:100%;min-width:0;height:32px;color:var(--text);font:inherit;background:#fff;border:1px solid #d5dde3;border-radius:7px;padding:0 8px;font-size:11px}.collectible-row input:focus{border-color:#8d7aba;outline:none;box-shadow:0 0 0 3px #6f5bae14}.collectible-file input{opacity:0;pointer-events:none;width:1px;height:1px;position:absolute}.collectible-file em{color:#625080;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;background:#f7f4fd;border:1px dashed #cfc4e1;border-radius:7px;align-items:center;gap:5px;min-width:0;height:32px;padding:0 8px;font-size:10px;font-style:normal;font-weight:700;display:flex;overflow:hidden}.collectible-inline-preview{color:#8c7cae;background:radial-gradient(circle,#fff,#eee8f8);border:1px solid #ded5ed;border-radius:8px;place-items:center;width:42px;height:42px;display:grid;overflow:hidden}.collectible-animation{width:100%;height:100%;overflow:hidden}.collectible-animation.compact{background:radial-gradient(circle,#fff,#f0ebfa);border:1px solid #e0d9ec;border-radius:8px;flex:0 0 42px;place-items:center;width:42px;height:42px;display:grid}.collectible-animation canvas{width:100%!important;height:100%!important}.collectible-animation.failed{color:#b42318;background:#fff4f2}.collectible-animation.loading{color:#807397}.collectible-file-error{color:#b42318;grid-column:1/-1;font-size:10px}.collectible-color input{cursor:pointer;height:32px!important;padding:3px!important}.collectible-backdrop-preview{border:1px solid #2a1f472e;border-radius:8px;flex:0 0 42px;place-items:center;width:42px;height:42px;font-size:11px;font-weight:900;display:grid;box-shadow:inset 0 0 0 1px #fff3}.collectible-row .icon-btn{align-self:center}.collectible-row .icon-btn:disabled{opacity:.28}@media (width<=900px){.gift-fields-grid,.collectible-row.animated,.collectible-row.backdrop{grid-template-columns:repeat(2,minmax(0,1fr))}.collectible-inline-preview,.collectible-backdrop-preview,.collectible-row .icon-btn{place-self:center start}}@media (width<=620px){.gift-import-note{flex-direction:column;align-items:flex-start}.gift-format-chips{justify-content:flex-start}.gift-file-picker{grid-template-columns:40px minmax(0,1fr)}.gift-file-action{display:none}.gift-fields-grid{grid-template-columns:1fr}.gift-list-summary{width:100%;margin-left:0}.official-gift-tools{flex-direction:column;align-items:stretch}.official-gift-list{grid-template-columns:1fr;max-height:340px}.official-gift-selected{grid-template-columns:82px minmax(0,1fr)}.official-gift-selected .gift-animation-shell{width:72px;height:72px}.collectible-modal-body{padding:10px}.collectible-definition-head,.collectible-section-head{flex-direction:column;align-items:flex-start}.collectible-row.animated,.collectible-row.backdrop{grid-template-columns:1fr}.collectible-active-grid{grid-template-columns:1fr 1fr}}.modal-backdrop{z-index:10000;background:#11182785;place-items:center;padding:24px;display:grid;position:fixed;inset:0}.modal{border:1px solid var(--line);width:min(760px,100%);max-height:min(820px,100vh - 48px);box-shadow:var(--shadow);background:#fff;border-radius:8px;padding:0;overflow:hidden}.command-modal{flex-direction:column;display:flex}.command-modal>.modal-head,.command-modal>.modal-actions{flex:none}.modal-head{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:12px;padding:16px 18px 12px;display:flex}.icon-btn{background:var(--panel-subtle);border:1px solid var(--line);cursor:pointer;border-radius:7px;place-items:center;width:30px;height:30px;display:grid}.command-steps{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;display:grid}.command-body{grid-auto-rows:max-content;gap:12px;min-height:0;padding:14px 18px;display:grid;overflow:auto}.command-step{min-height:38px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;align-items:center;gap:8px;padding:0 10px;display:flex}.command-step span{border:1px solid var(--line);background:#fff;border-radius:999px;place-items:center;width:20px;height:20px;font-size:11px;font-weight:800;display:grid}.command-step.active{color:var(--brand);border-color:#a9d8ce}.command-step.done{color:var(--good);border-color:#b9dcc7}.form-field{gap:6px;display:grid}.form-field span,.form-stack span{color:#4b5563;font-weight:800}.command-preview{gap:8px;display:grid}.command-preview .json-block{max-height:150px}.preview-head,.result-title{color:#344054;align-items:center;gap:7px;font-weight:800;display:flex}.result-box{border:1px solid var(--line);background:#fbfcfd;border-radius:8px;gap:8px;padding:10px;display:grid}.result-line{grid-template-columns:92px minmax(0,1fr);gap:8px;display:grid}.result-line span{color:var(--muted)}.result-line strong{overflow-wrap:anywhere}.result-message{color:#344054}.modal-actions{border-top:1px solid var(--line);background:#fff;justify-content:flex-end;padding:12px 18px}.login-page{background:var(--bg);place-items:center;min-height:100vh;padding:24px;display:grid}.login-panel{border:1px solid var(--line);width:min(420px,100%);box-shadow:var(--shadow);background:#fff;border-radius:8px;gap:18px;padding:22px;display:grid}.login-head{justify-content:space-between;align-items:center;gap:12px;display:flex}.login-head-actions{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px;display:flex}.login-chip{min-height:24px;color:var(--brand);background:#edf7f4;border:1px solid #c9e2dc;border-radius:999px;align-items:center;padding:0 8px;font-size:12px;display:inline-flex}.login-copy h1{margin:0;font-size:22px}.login-copy p{color:var(--muted);margin:8px 0 0}.form-stack{gap:12px;display:grid}.form-stack label{gap:6px;display:grid}.form-stack input{width:100%}.boot-screen{align-content:center;place-items:center;gap:18px;min-height:100vh;display:grid}.loader-bar{background:#d7dde4;border-radius:999px;width:180px;height:4px;overflow:hidden}.loader-bar:before{content:"";background:var(--brand);width:42%;height:100%;animation:1s ease-in-out infinite load;display:block}.spin{animation:.8s linear infinite spin}@keyframes load{0%{transform:translate(-120%)}to{transform:translate(260%)}}@keyframes spin{to{transform:rotate(360deg)}}@media (width<=1120px){.shell{grid-template-columns:1fr}.sidebar{height:auto;position:static}.nav-list{grid-template-columns:repeat(4,minmax(0,1fr))}.sidebar-status{display:none}.overview-band,.split-layout,.operation-row,.raw-grid,.message-selector-grid,.message-selector-grid.single{grid-template-columns:1fr}.action-dock{position:static}}@media (width<=760px){.content,.topbar{padding-left:14px;padding-right:14px}.command-grid,.work-strip,.overview-metrics,.metric-row,.summary-grid,.command-steps{grid-template-columns:1fr}.sidebar{gap:12px;padding:14px}.nav-list{grid-template-columns:repeat(2,minmax(0,1fr))}.topbar,.page-title-row,.entity-head{flex-direction:column;align-items:flex-start}input,.searchbox{width:100%}.toolbar{align-items:stretch}.picker-row,.selected-entity{grid-template-columns:1fr}} diff --git a/cmd/telesrv-admin/web/dist/assets/index-DKmJO2ZY.js b/cmd/telesrv-admin/web/dist/assets/index-DKmJO2ZY.js new file mode 100644 index 00000000..0638b32e --- /dev/null +++ b/cmd/telesrv-admin/web/dist/assets/index-DKmJO2ZY.js @@ -0,0 +1,9 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.provider`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.iterator;function p(e){return typeof e!=`object`||!e?null:(e=f&&e[f]||e[`@@iterator`],typeof e==`function`?e:null)}var m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function _(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}_.prototype.isReactComponent={},_.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`setState(...): takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},_.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function v(){}v.prototype=_.prototype;function y(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}var b=y.prototype=new v;b.constructor=y,h(b,_.prototype),b.isPureReactComponent=!0;var x=Array.isArray,S=Object.prototype.hasOwnProperty,C={current:null},w={key:!0,ref:!0,__self:!0,__source:!0};function T(e,n,r){var i,a={},o=null,s=null;if(n!=null)for(i in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(o=``+n.key),n)S.call(n,i)&&!w.hasOwnProperty(i)&&(a[i]=n[i]);var c=arguments.length-2;if(c===1)a.children=r;else if(1{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=typeof setTimeout==`function`?setTimeout:null,_=typeof clearTimeout==`function`?clearTimeout:null,v=typeof setImmediate<`u`?setImmediate:null;typeof navigator<`u`&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function b(e){if(h=!1,y(e),!m)if(n(c)!==null)m=!0,M(x);else{var t=n(l);t!==null&&N(b,t.startTime-e)}}function x(t,i){m=!1,h&&(h=!1,_(w),w=-1),p=!0;var a=f;try{for(y(i),d=n(c);d!==null&&(!(d.expirationTime>i)||t&&!D());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=i);i=e.unstable_now(),typeof s==`function`?d.callback=s:d===n(c)&&r(c),y(i)}else r(c);d=n(c)}if(d!==null)var u=!0;else{var g=n(l);g!==null&&N(b,g.startTime-i),u=!1}return u}finally{d=null,f=a,p=!1}}var S=!1,C=null,w=-1,T=5,E=-1;function D(){return!(e.unstable_now()-Ee||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(_(w),w=-1):h=!0,N(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,M(x))),r},e.unstable_shouldYield=D,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u(),n=f();function r(e){for(var t=`https://reactjs.org/docs/error-decoder.html?invariant=`+e,n=1;n`u`||window.document===void 0||window.document.createElement===void 0),l=Object.prototype.hasOwnProperty,d=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,p={},m={};function h(e){return l.call(m,e)?!0:l.call(p,e)?!1:d.test(e)?m[e]=!0:(p[e]=!0,!1)}function g(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case`function`:case`symbol`:return!0;case`boolean`:return r?!1:n===null?(e=e.toLowerCase().slice(0,5),e!==`data-`&&e!==`aria-`):!n.acceptsBooleans;default:return!1}}function _(e,t,n,r){if(t==null||g(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return!1===t;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function v(e,t,n,r,i,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var y={};`children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style`.split(` `).forEach(function(e){y[e]=new v(e,0,!1,e,null,!1,!1)}),[[`acceptCharset`,`accept-charset`],[`className`,`class`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`]].forEach(function(e){var t=e[0];y[t]=new v(t,1,!1,e[1],null,!1,!1)}),[`contentEditable`,`draggable`,`spellCheck`,`value`].forEach(function(e){y[e]=new v(e,2,!1,e.toLowerCase(),null,!1,!1)}),[`autoReverse`,`externalResourcesRequired`,`focusable`,`preserveAlpha`].forEach(function(e){y[e]=new v(e,2,!1,e,null,!1,!1)}),`allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope`.split(` `).forEach(function(e){y[e]=new v(e,3,!1,e.toLowerCase(),null,!1,!1)}),[`checked`,`multiple`,`muted`,`selected`].forEach(function(e){y[e]=new v(e,3,!0,e,null,!1,!1)}),[`capture`,`download`].forEach(function(e){y[e]=new v(e,4,!1,e,null,!1,!1)}),[`cols`,`rows`,`size`,`span`].forEach(function(e){y[e]=new v(e,6,!1,e,null,!1,!1)}),[`rowSpan`,`start`].forEach(function(e){y[e]=new v(e,5,!1,e.toLowerCase(),null,!1,!1)});var b=/[\-:]([a-z])/g;function x(e){return e[1].toUpperCase()}`accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,null,!1,!1)}),`xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/1999/xlink`,!1,!1)}),[`xml:base`,`xml:lang`,`xml:space`].forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/XML/1998/namespace`,!1,!1)}),[`tabIndex`,`crossOrigin`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!1,!1)}),y.xlinkHref=new v(`xlinkHref`,1,!1,`xlink:href`,`http://www.w3.org/1999/xlink`,!0,!1),[`src`,`href`,`action`,`formAction`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!0,!0)});function S(e,t,n,r){var i=y.hasOwnProperty(t)?y[t]:null;(i===null?r||!(2s||i[o]!==a[s]){var c=` +`+i[o].replace(` at new `,` at `);return e.displayName&&c.includes(``)&&(c=c.replace(``,e.displayName)),c}while(1<=o&&0<=s);break}}}finally{re=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:``)?ne(e):``}function ae(e){switch(e.tag){case 5:return ne(e.type);case 16:return ne(`Lazy`);case 13:return ne(`Suspense`);case 19:return ne(`SuspenseList`);case 0:case 2:case 15:return e=ie(e.type,!1),e;case 11:return e=ie(e.type.render,!1),e;case 1:return e=ie(e.type,!0),e;default:return``}}function oe(e){if(e==null)return null;if(typeof e==`function`)return e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case E:return`Fragment`;case T:return`Portal`;case O:return`Profiler`;case D:return`StrictMode`;case M:return`Suspense`;case N:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case A:return(e.displayName||`Context`)+`.Consumer`;case k:return(e._context.displayName||`Context`)+`.Provider`;case j:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case P:return t=e.displayName||null,t===null?oe(e.type)||`Memo`:t;case F:t=e._payload,e=e._init;try{return oe(e(t))}catch{}}return null}function se(e){var t=e.type;switch(e.tag){case 24:return`Cache`;case 9:return(t.displayName||`Context`)+`.Consumer`;case 10:return(t._context.displayName||`Context`)+`.Provider`;case 18:return`DehydratedFragment`;case 11:return e=t.render,e=e.displayName||e.name||``,t.displayName||(e===``?`ForwardRef`:`ForwardRef(`+e+`)`);case 7:return`Fragment`;case 5:return t;case 4:return`Portal`;case 3:return`Root`;case 6:return`Text`;case 16:return oe(t);case 8:return t===D?`StrictMode`:`Mode`;case 22:return`Offscreen`;case 12:return`Profiler`;case 21:return`Scope`;case 13:return`Suspense`;case 19:return`SuspenseList`;case 25:return`TracingMarker`;case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t==`function`)return t.displayName||t.name||null;if(typeof t==`string`)return t}return null}function ce(e){switch(typeof e){case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function le(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function ue(e){var t=le(e)?`checked`:`value`,n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=``+e[t];if(!e.hasOwnProperty(t)&&n!==void 0&&typeof n.get==`function`&&typeof n.set==`function`){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){r=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function z(e){e._valueTracker||=ue(e)}function de(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=le(e)?e.checked?`true`:`false`:e.value),e=r,e===n?!1:(t.setValue(e),!0)}function B(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function V(e,t){var n=t.checked;return R({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function fe(e,t){var n=t.defaultValue==null?``:t.defaultValue,r=t.checked==null?t.defaultChecked:t.checked;n=ce(t.value==null?n:t.value),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type===`checkbox`||t.type===`radio`?t.checked!=null:t.value!=null}}function pe(e,t){t=t.checked,t!=null&&S(e,`checked`,t,!1)}function me(e,t){pe(e,t);var n=ce(t.value),r=t.type;if(n!=null)r===`number`?(n===0&&e.value===``||e.value!=n)&&(e.value=``+n):e.value!==``+n&&(e.value=``+n);else if(r===`submit`||r===`reset`){e.removeAttribute(`value`);return}t.hasOwnProperty(`value`)?ge(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&ge(e,t.type,ce(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function he(e,t,n){if(t.hasOwnProperty(`value`)||t.hasOwnProperty(`defaultValue`)){var r=t.type;if(!(r!==`submit`&&r!==`reset`||t.value!==void 0&&t.value!==null))return;t=``+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==``&&(e.name=``),e.defaultChecked=!!e._wrapperState.initialChecked,n!==``&&(e.name=n)}function ge(e,t,n){(t!==`number`||B(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var _e=Array.isArray;function ve(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=Ce.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Te(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Ee={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},De=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(Ee).forEach(function(e){De.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Ee[t]=Ee[e]})});function Oe(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||Ee.hasOwnProperty(e)&&Ee[e]?(``+t).trim():t+`px`}function ke(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=Oe(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var Ae=R({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function je(e,t){if(t){if(Ae[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(r(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(r(60));if(typeof t.dangerouslySetInnerHTML!=`object`||!(`__html`in t.dangerouslySetInnerHTML))throw Error(r(61))}if(t.style!=null&&typeof t.style!=`object`)throw Error(r(62))}}function Me(e,t){if(e.indexOf(`-`)===-1)return typeof t.is==`string`;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var Ne=null;function Pe(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Fe=null,Ie=null,Le=null;function Re(e){if(e=ji(e)){if(typeof Fe!=`function`)throw Error(r(280));var t=e.stateNode;t&&(t=Ni(t),Fe(e.stateNode,e.type,t))}}function ze(e){Ie?Le?Le.push(e):Le=[e]:Ie=e}function Be(){if(Ie){var e=Ie,t=Le;if(Le=Ie=null,Re(e),t)for(e=0;e>>=0,e===0?32:31-(vt(e)/yt|0)|0}var xt=64,St=4194304;function Ct(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function wt(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~i;s===0?(a&=o,a!==0&&(r=Ct(a))):r=Ct(s)}else o=n&~i,o===0?a!==0&&(r=Ct(a)):r=Ct(o);if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&a&4194240))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function At(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-_t(t),e[t]=n}function jt(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=X),Jn=` `,Yn=!1;function Xn(e,t){switch(e){case`keyup`:return Wn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Zn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Qn=!1;function $n(e,t){switch(e){case`compositionend`:return Zn(t);case`keypress`:return t.which===32?(Yn=!0,Jn):null;case`textInput`:return e=t.data,e===Jn&&Yn?null:e;default:return null}}function er(e,t){if(Qn)return e===`compositionend`||!Gn&&Xn(e,t)?(e=hn(),mn=pn=fn=null,Qn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=xr(n)}}function Cr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Cr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function wr(){for(var e=window,t=B();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=B(e.document)}return t}function Tr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}function Er(e){var t=wr(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Cr(n.ownerDocument.documentElement,n)){if(r!==null&&Tr(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),`selectionStart`in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=Sr(n,a);var o=Sr(n,r);i&&o&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus==`function`&&n.focus(),n=0;n=document.documentMode,Or=null,kr=null,Ar=null,jr=!1;function Mr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;jr||Or==null||Or!==B(r)||(r=Or,`selectionStart`in r&&Tr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Ar&&br(Ar,r)||(Ar=r,r=ii(kr,`onSelect`),0Fi||(e.current=Pi[Fi],Pi[Fi]=null,Fi--)}function Ri(e,t){Fi++,Pi[Fi]=e.current,e.current=t}var zi={},Bi=Ii(zi),Vi=Ii(!1),Hi=zi;function Ui(e,t){var n=e.type.contextTypes;if(!n)return zi;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function Wi(e){return e=e.childContextTypes,e!=null}function Gi(){Li(Vi),Li(Bi)}function Ki(e,t,n){if(Bi.current!==zi)throw Error(r(168));Ri(Bi,t),Ri(Vi,n)}function qi(e,t,n){var i=e.stateNode;if(t=t.childContextTypes,typeof i.getChildContext!=`function`)return n;for(var a in i=i.getChildContext(),i)if(!(a in t))throw Error(r(108,se(e)||`Unknown`,a));return R({},n,i)}function Ji(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||zi,Hi=Bi.current,Ri(Bi,e),Ri(Vi,Vi.current),!0}function Yi(e,t,n){var i=e.stateNode;if(!i)throw Error(r(169));n?(e=qi(e,t,Hi),i.__reactInternalMemoizedMergedChildContext=e,Li(Vi),Li(Bi),Ri(Bi,e)):Li(Vi),Ri(Vi,n)}var Xi=null,Zi=!1,Qi=!1;function $i(e){Xi===null?Xi=[e]:Xi.push(e)}function ea(e){Zi=!0,$i(e)}function ta(){if(!Qi&&Xi!==null){Qi=!0;var e=0,t=Y;try{var n=Xi;for(Y=1;e>=o,i-=o,la=1<<32-_t(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(r,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(r,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(r,d),_a&&da(r,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),_a&&da(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return _a&&da(a,g),u}for(h=i(a,h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),_a&&da(a,g),u}function _(e,r,i,o){if(typeof i==`object`&&i&&i.type===E&&i.key===null&&(i=i.props.children),typeof i==`object`&&i){switch(i.$$typeof){case w:a:{for(var c=i.key,l=r;l!==null;){if(l.key===c){if(c=i.type,c===E){if(l.tag===7){n(e,l.sibling),r=a(l,i.props.children),r.return=e,e=r;break a}}else if(l.elementType===c||typeof c==`object`&&c&&c.$$typeof===F&&ja(c)===l.type){n(e,l.sibling),r=a(l,i.props),r.ref=ka(e,l,i),r.return=e,e=r;break a}n(e,l);break}else t(e,l);l=l.sibling}i.type===E?(r=Zl(i.props.children,e.mode,o,i.key),r.return=e,e=r):(o=Xl(i.type,i.key,i.props,null,e.mode,o),o.ref=ka(e,r,i),o.return=e,e=o)}return s(e);case T:a:{for(l=i.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===i.containerInfo&&r.stateNode.implementation===i.implementation){n(e,r.sibling),r=a(r,i.children||[]),r.return=e,e=r;break a}else{n(e,r);break}else t(e,r);r=r.sibling}r=eu(i,e.mode,o),r.return=e,e=r}return s(e);case F:return l=i._init,_(e,r,l(i._payload),o)}if(_e(i))return h(e,r,i,o);if(ee(i))return g(e,r,i,o);Aa(e,i)}return typeof i==`string`&&i!==``||typeof i==`number`?(i=``+i,r!==null&&r.tag===6?(n(e,r.sibling),r=a(r,i),r.return=e,e=r):(n(e,r),r=$l(i,e.mode,o),r.return=e,e=r),s(e)):n(e,r)}return _}var Na=Ma(!0),Pa=Ma(!1),Fa=Ii(null),Ia=null,La=null,Ra=null;function za(){Ra=La=Ia=null}function Ba(e){var t=Fa.current;Li(Fa),e._currentValue=t}function Va(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function Ha(e,t){Ia=e,Ra=La=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ms=!0),e.firstContext=null)}function Ua(e){var t=e._currentValue;if(Ra!==e)if(e={context:e,memoizedValue:t,next:null},La===null){if(Ia===null)throw Error(r(308));La=e,Ia.dependencies={lanes:0,firstContext:e}}else La=La.next=e;return t}var Wa=null;function Ga(e){Wa===null?Wa=[e]:Wa.push(e)}function Ka(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,Ga(t)):(n.next=i.next,i.next=n),t.interleaved=n,qa(e,r)}function qa(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Ja=!1;function Ya(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Xa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Za(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Qa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,$&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,qa(e,n)}return i=r.interleaved,i===null?(t.next=t,Ga(r)):(t.next=i.next,i.next=t),r.interleaved=t,qa(e,n)}function $a(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194240)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Mt(e,n)}}function eo(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function to(e,t,n,r){var i=e.updateQueue;Ja=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane,p=s.eventTime;if((r&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});a:{var m=e,h=s;switch(f=t,p=n,h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(p,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(p,d,f):m,f==null)break a;d=R({},d,f);break a;case 2:Ja=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=i.effects,f===null?i.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;f=s,s=f.next,f.next=null,i.lastBaseUpdate=f,i.shared.pending=null}}while(1);if(u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,t=i.shared.interleaved,t!==null){i=t;do o|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);Jc|=o,e.lanes=o,e.memoizedState=d}}function no(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=vo.transition;vo.transition={};try{e(!1),t()}finally{Y=n,vo.transition=r}}function as(){return Mo().memoizedState}function os(e,t,n){var r=pl(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},cs(e))ls(t,n);else if(n=Ka(e,t,n,r),n!==null){var i=fl();ml(n,e,r,i),us(n,t,r)}}function ss(e,t,n){var r=pl(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(cs(e))ls(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Z(s,o)){var c=t.interleaved;c===null?(i.next=i,Ga(t)):(i.next=c.next,c.next=i),t.interleaved=i;return}}catch{}n=Ka(e,t,i,r),n!==null&&(i=fl(),ml(n,e,r,i),us(n,t,r))}}function cs(e){var t=e.alternate;return e===bo||t!==null&&t===bo}function ls(e,t){wo=Co=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function us(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Mt(e,n)}}var ds={readContext:Ua,useCallback:Do,useContext:Do,useEffect:Do,useImperativeHandle:Do,useInsertionEffect:Do,useLayoutEffect:Do,useMemo:Do,useReducer:Do,useRef:Do,useState:Do,useDebugValue:Do,useDeferredValue:Do,useTransition:Do,useMutableSource:Do,useSyncExternalStore:Do,useId:Do,unstable_isNewReconciler:!1},fs={readContext:Ua,useCallback:function(e,t){return jo().memoizedState=[e,t===void 0?null:t],e},useContext:Ua,useEffect:Jo,useImperativeHandle:function(e,t,n){return n=n==null?null:n.concat([e]),Ko(4194308,4,Qo.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ko(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ko(4,2,e,t)},useMemo:function(e,t){var n=jo();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=jo();return t=n===void 0?t:n(t),r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=os.bind(null,bo,e),[r.memoizedState,e]},useRef:function(e){var t=jo();return e={current:e},t.memoizedState=e},useState:Uo,useDebugValue:es,useDeferredValue:function(e){return jo().memoizedState=e},useTransition:function(){var e=Uo(!1),t=e[0];return e=is.bind(null,e[1]),jo().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var i=bo,a=jo();if(_a){if(n===void 0)throw Error(r(407));n=n()}else{if(n=t(),Vc===null)throw Error(r(349));yo&30||Ro(i,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,Jo(Bo.bind(null,i,o,e),[e]),i.flags|=2048,Wo(9,zo.bind(null,i,o,n,t),void 0,null),n},useId:function(){var e=jo(),t=Vc.identifierPrefix;if(_a){var n=ua,r=la;n=(r&~(1<<32-_t(r)-1)).toString(32)+n,t=`:`+t+`R`+n,n=To++,0<\/script>`,e=e.removeChild(e.firstChild)):typeof i.is==`string`?e=c.createElement(n,{is:i.is}):(e=c.createElement(n),n===`select`&&(c=e,i.multiple?c.multiple=!0:i.size&&(c.size=i.size))):e=c.createElementNS(e,n),e[wi]=t,e[Ti]=i,nc(e,t,!1,!1),t.stateNode=e;a:{switch(c=Me(n,i),n){case`dialog`:Zr(`cancel`,e),Zr(`close`,e),o=i;break;case`iframe`:case`object`:case`embed`:Zr(`load`,e),o=i;break;case`video`:case`audio`:for(o=0;oel&&(t.flags|=128,i=!0,ac(s,!1),t.lanes=4194304)}else{if(!i)if(e=mo(c),e!==null){if(t.flags|=128,i=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),ac(s,!0),s.tail===null&&s.tailMode===`hidden`&&!c.alternate&&!_a)return oc(t),null}else 2*st()-s.renderingStartTime>el&&n!==1073741824&&(t.flags|=128,i=!0,ac(s,!1),t.lanes=4194304);s.isBackwards?(c.sibling=t.child,t.child=c):(n=s.last,n===null?t.child=c:n.sibling=c,s.last=c)}return s.tail===null?(oc(t),null):(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=st(),t.sibling=null,n=po.current,Ri(po,i?n&1|2:n&1),t);case 22:case 23:return wl(),i=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==i&&(t.flags|=8192),i&&t.mode&1?Wc&1073741824&&(oc(t),t.subtreeFlags&6&&(t.flags|=8192)):oc(t),null;case 24:return null;case 25:return null}throw Error(r(156,t.tag))}function cc(e,t){switch(ma(t),t.tag){case 1:return Wi(t.type)&&Gi(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return lo(),Li(Vi),Li(Bi),go(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return fo(t),null;case 13:if(Li(po),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(r(340));Ea()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Li(po),null;case 4:return lo(),null;case 10:return Ba(t.type._context),null;case 22:case 23:return wl(),null;case 24:return null;default:return null}}var lc=!1,uc=!1,dc=typeof WeakSet==`function`?WeakSet:Set,Q=null;function fc(e,t){var n=e.ref;if(n!==null)if(typeof n==`function`)try{n(null)}catch(n){Rl(e,t,n)}else n.current=null}function pc(e,t,n){try{n()}catch(n){Rl(e,t,n)}}var mc=!1;function hc(e,t){if(fi=an,e=wr(),Tr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var i=n.getSelection&&n.getSelection();if(i&&i.rangeCount!==0){n=i.anchorNode;var a=i.anchorOffset,o=i.focusNode;i=i.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||i!==0&&f.nodeType!==3||(l=s+i),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===i&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(pi={focusedElem:e,selectionRange:n},an=!1,Q=t;Q!==null;)if(t=Q,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,Q=e;else for(;Q!==null;){t=Q;try{var h=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(h!==null){var g=h.memoizedProps,_=h.memoizedState,v=t.stateNode;v.__reactInternalSnapshotBeforeUpdate=v.getSnapshotBeforeUpdate(t.elementType===t.type?g:hs(t.type,g),_)}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent=``:y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(r(163))}}catch(e){Rl(t,t.return,e)}if(e=t.sibling,e!==null){e.return=t.return,Q=e;break}Q=t.return}return h=mc,mc=!1,h}function gc(e,t,n){var r=t.updateQueue;if(r=r===null?null:r.lastEffect,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&pc(t,n,a)}i=i.next}while(i!==r)}}function _c(e,t){if(t=t.updateQueue,t=t===null?null:t.lastEffect,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function vc(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t==`function`?t(e):t.current=e}}function yc(e){var t=e.alternate;t!==null&&(e.alternate=null,yc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[wi],delete t[Ti],delete t[Di],delete t[Oi],delete t[ki])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function bc(e){return e.tag===5||e.tag===3||e.tag===4}function xc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||bc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Sc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=di));else if(r!==4&&(e=e.child,e!==null))for(Sc(e,t,n),e=e.sibling;e!==null;)Sc(e,t,n),e=e.sibling}function Cc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Cc(e,t,n),e=e.sibling;e!==null;)Cc(e,t,n),e=e.sibling}var wc=null,Tc=!1;function Ec(e,t,n){for(n=n.child;n!==null;)Dc(e,t,n),n=n.sibling}function Dc(e,t,n){if(ht&&typeof ht.onCommitFiberUnmount==`function`)try{ht.onCommitFiberUnmount(mt,n)}catch{}switch(n.tag){case 5:uc||fc(n,t);case 6:var r=wc,i=Tc;wc=null,Ec(e,t,n),wc=r,Tc=i,wc!==null&&(Tc?(e=wc,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):wc.removeChild(n.stateNode));break;case 18:wc!==null&&(Tc?(e=wc,n=n.stateNode,e.nodeType===8?bi(e.parentNode,n):e.nodeType===1&&bi(e,n),nn(e)):bi(wc,n.stateNode));break;case 4:r=wc,i=Tc,wc=n.stateNode.containerInfo,Tc=!0,Ec(e,t,n),wc=r,Tc=i;break;case 0:case 11:case 14:case 15:if(!uc&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&pc(n,t,o),i=i.next}while(i!==r)}Ec(e,t,n);break;case 1:if(!uc&&(fc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(e){Rl(n,t,e)}Ec(e,t,n);break;case 21:Ec(e,t,n);break;case 22:n.mode&1?(uc=(r=uc)||n.memoizedState!==null,Ec(e,t,n),uc=r):Ec(e,t,n);break;default:Ec(e,t,n)}}function Oc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new dc),t.forEach(function(t){var r=Hl.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))})}}function kc(e,t){var n=t.deletions;if(n!==null)for(var i=0;ia&&(a=s),i&=~o}if(i=a,i=st()-i,i=(120>i?120:480>i?480:1080>i?1080:1920>i?1920:3e3>i?3e3:4320>i?4320:1960*Lc(i/1960))-i,10e?16:e,ol===null)var i=!1;else{if(e=ol,ol=null,sl=0,$&6)throw Error(r(331));var a=$;for($|=4,Q=e.current;Q!==null;){var o=Q,s=o.child;if(Q.flags&16){var c=o.deletions;if(c!==null){for(var l=0;lst()-$c?Tl(e,0):Xc|=n),hl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=St,St<<=1,!(St&130023424)&&(St=4194304)):t=1);var n=fl();e=qa(e,t),e!==null&&(At(e,t,n),hl(e,n))}function Vl(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bl(e,n)}function Hl(e,t){var n=0;switch(e.tag){case 13:var i=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:i=e.stateNode;break;default:throw Error(r(314))}i!==null&&i.delete(t),Bl(e,n)}var Ul=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Vi.current)Ms=!0;else{if((e.lanes&n)===0&&!(t.flags&128))return Ms=!1,tc(e,t,n);Ms=!!(e.flags&131072)}else Ms=!1,_a&&t.flags&1048576&&fa(t,aa,t.index);switch(t.lanes=0,t.tag){case 2:var i=t.type;$s(e,t),e=t.pendingProps;var a=Ui(t,Bi.current);Ha(t,n),a=ko(null,t,i,e,a,n);var o=Ao();return t.flags|=1,typeof a==`object`&&a&&typeof a.render==`function`&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Wi(i)?(o=!0,Ji(t)):o=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,Ya(t),a.updater=_s,t.stateNode=a,a._reactInternals=t,xs(t,i,e,n),t=Vs(null,t,i,!0,o,n)):(t.tag=0,_a&&o&&pa(t),Ns(null,t,a,n),t=t.child),t;case 16:i=t.elementType;a:{switch($s(e,t),e=t.pendingProps,a=i._init,i=a(i._payload),t.type=i,a=t.tag=Jl(i),e=hs(i,e),a){case 0:t=zs(null,t,i,e,n);break a;case 1:t=Bs(null,t,i,e,n);break a;case 11:t=Ps(null,t,i,e,n);break a;case 14:t=Fs(null,t,i,hs(i.type,e),n);break a}throw Error(r(306,i,``))}return t;case 0:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),zs(e,t,i,a,n);case 1:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),Bs(e,t,i,a,n);case 3:a:{if(Hs(t),e===null)throw Error(r(387));i=t.pendingProps,o=t.memoizedState,a=o.element,Xa(e,t),to(t,i,null,n);var s=t.memoizedState;if(i=s.element,o.isDehydrated)if(o={element:i,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){a=Ss(Error(r(423)),t),t=Us(e,t,i,n,a);break a}else if(i!==a){a=Ss(Error(r(424)),t),t=Us(e,t,i,n,a);break a}else for(ga=xi(t.stateNode.containerInfo.firstChild),ha=t,_a=!0,va=null,n=Pa(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Ea(),i===a){t=ec(e,t,n);break a}Ns(e,t,i,n)}t=t.child}return t;case 5:return uo(t),e===null&&Sa(t),i=t.type,a=t.pendingProps,o=e===null?null:e.memoizedProps,s=a.children,mi(i,a)?s=null:o!==null&&mi(i,o)&&(t.flags|=32),Rs(e,t),Ns(e,t,s,n),t.child;case 6:return e===null&&Sa(t),null;case 13:return Ks(e,t,n);case 4:return co(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=Na(t,null,i,n):Ns(e,t,i,n),t.child;case 11:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),Ps(e,t,i,a,n);case 7:return Ns(e,t,t.pendingProps,n),t.child;case 8:return Ns(e,t,t.pendingProps.children,n),t.child;case 12:return Ns(e,t,t.pendingProps.children,n),t.child;case 10:a:{if(i=t.type._context,a=t.pendingProps,o=t.memoizedProps,s=a.value,Ri(Fa,i._currentValue),i._currentValue=s,o!==null)if(Z(o.value,s)){if(o.children===a.children&&!Vi.current){t=ec(e,t,n);break a}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){s=o.child;for(var l=c.firstContext;l!==null;){if(l.context===i){if(o.tag===1){l=Za(-1,n&-n),l.tag=2;var u=o.updateQueue;if(u!==null){u=u.shared;var d=u.pending;d===null?l.next=l:(l.next=d.next,d.next=l),u.pending=l}}o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),Va(o.return,n,t),c.lanes|=n;break}l=l.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(r(341));s.lanes|=n,c=s.alternate,c!==null&&(c.lanes|=n),Va(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}Ns(e,t,a.children,n),t=t.child}return t;case 9:return a=t.type,i=t.pendingProps.children,Ha(t,n),a=Ua(a),i=i(a),t.flags|=1,Ns(e,t,i,n),t.child;case 14:return i=t.type,a=hs(i,t.pendingProps),a=hs(i.type,a),Fs(e,t,i,a,n);case 15:return Is(e,t,t.type,t.pendingProps,n);case 17:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),$s(e,t),t.tag=1,Wi(i)?(e=!0,Ji(t)):e=!1,Ha(t,n),ys(t,i,a),xs(t,i,a,n),Vs(null,t,i,!0,e,n);case 19:return Qs(e,t,n);case 22:return Ls(e,t,n)}throw Error(r(156,t.tag))};function Wl(e,t){return rt(e,t)}function Gl(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Kl(e,t,n,r){return new Gl(e,t,n,r)}function ql(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Jl(e){if(typeof e==`function`)return+!!ql(e);if(e!=null){if(e=e.$$typeof,e===j)return 11;if(e===P)return 14}return 2}function Yl(e,t){var n=e.alternate;return n===null?(n=Kl(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Xl(e,t,n,i,a,o){var s=2;if(i=e,typeof e==`function`)ql(e)&&(s=1);else if(typeof e==`string`)s=5;else a:switch(e){case E:return Zl(n.children,a,o,t);case D:s=8,a|=8;break;case O:return e=Kl(12,n,t,a|2),e.elementType=O,e.lanes=o,e;case M:return e=Kl(13,n,t,a),e.elementType=M,e.lanes=o,e;case N:return e=Kl(19,n,t,a),e.elementType=N,e.lanes=o,e;case I:return Ql(n,a,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case k:s=10;break a;case A:s=9;break a;case j:s=11;break a;case P:s=14;break a;case F:s=16,i=null;break a}throw Error(r(130,e==null?e:typeof e,``))}return t=Kl(s,n,t,a),t.elementType=e,t.type=i,t.lanes=o,t}function Zl(e,t,n,r){return e=Kl(7,e,r,t),e.lanes=n,e}function Ql(e,t,n,r){return e=Kl(22,e,r,t),e.elementType=I,e.lanes=n,e.stateNode={isHidden:!1},e}function $l(e,t,n){return e=Kl(6,e,null,t),e.lanes=n,e}function eu(e,t,n){return t=Kl(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function tu(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=kt(0),this.expirationTimes=kt(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=kt(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function nu(e,t,n,r,i,a,o,s,c){return e=new tu(e,t,n,s,c),t===1?(t=1,!0===a&&(t|=8)):t=0,a=Kl(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ya(a),e}function ru(e,t,n){var r=3{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=m();e.createRoot=t.createRoot,e.hydrateRoot=t.hydrateRoot})),g=c(u()),_=c(h(),1),v=class extends Error{status;constructor(e,t){super(t),this.status=e}};async function y(e,t={}){let n=typeof FormData<`u`&&t.body instanceof FormData,r=await fetch(e,{credentials:`same-origin`,headers:n?t.headers:{"Content-Type":`application/json`,...t.headers??{}},...t}),i=await r.text(),a=i?JSON.parse(i):null;if(!r.ok){let e=a?.error||a?.Error||a?.message||r.statusText;throw new v(r.status,e)}return a}function b(e){return e instanceof Error?e.message:String(e)}var x={session:()=>y(`/api/session`),login:e=>y(`/api/login`,{method:`POST`,body:JSON.stringify({secret:e})}),logout:()=>y(`/api/logout`,{method:`POST`,body:`{}`}),accounts:e=>y(`/api/accounts?${e.toString()}`),account:e=>y(`/api/accounts/${e}`),channels:e=>y(`/api/channels?${e.toString()}`),channel:e=>y(`/api/channels/${e}`),messages:e=>y(`/api/messages?${e.toString()}`),message:(e,t)=>y(`/api/messages/detail?${new URLSearchParams({owner_user_id:String(e),msg_id:String(t)}).toString()}`),groupMessages:e=>y(`/api/messages/groups?${e.toString()}`),groupMessage:(e,t)=>y(`/api/messages/groups/detail?${new URLSearchParams({channel_id:String(e),msg_id:String(t)}).toString()}`),gifts:()=>y(`/api/gifts`),officialGifts:()=>y(`/api/official-gifts`),officialGiftAnimation:e=>y(`/api/official-gifts/${encodeURIComponent(e)}/animation`),giftAnimation:e=>y(`/api/gifts/${encodeURIComponent(e)}/animation`),giftCollectibles:e=>y(`/api/gifts/${encodeURIComponent(e)}/collectibles`),giftCollectibleAnimation:(e,t,n)=>y(`/api/gifts/${encodeURIComponent(e)}/collectibles/${t}/${encodeURIComponent(n)}/animation`),importGift:e=>y(`/api/actions/import-gift`,{method:`POST`,body:e}),importOfficialGift:e=>y(`/api/actions/import-official-gift`,{method:`POST`,body:JSON.stringify(e)}),publishGiftCollectibles:(e,t)=>y(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(e)}`,{method:`POST`,body:t}),action:(e,t)=>y(e,{method:`POST`,body:JSON.stringify(t)})},S=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),C=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),w={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},T=(0,g.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>(0,g.createElement)(`svg`,{ref:c,...w,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:C(`lucide`,i),...s},[...o.map(([e,t])=>(0,g.createElement)(e,t)),...Array.isArray(a)?a:[a]])),E=(e,t)=>{let n=(0,g.forwardRef)(({className:n,...r},i)=>(0,g.createElement)(T,{ref:i,iconNode:t,className:C(`lucide-${S(e)}`,n),...r}));return n.displayName=`${e}`,n},D=E(`BadgeCheck`,[[`path`,{d:`M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z`,key:`3c2336`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),O=E(`CircleAlert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]),k=E(`CircleCheck`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),A=E(`LoaderCircle`,[[`path`,{d:`M21 12a9 9 0 1 1-6.219-8.56`,key:`13zald`}]]),j=E(`Sparkles`,[[`path`,{d:`M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z`,key:`4pj2yx`}],[`path`,{d:`M20 3v4`,key:`1olli1`}],[`path`,{d:`M22 5h-4`,key:`1gvqau`}],[`path`,{d:`M4 17v2`,key:`vumght`}],[`path`,{d:`M5 18H3`,key:`zchphs`}]]),M=E(`ArrowLeft`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),N=E(`Cable`,[[`path`,{d:`M17 21v-2a1 1 0 0 1-1-1v-1a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1`,key:`10bnsj`}],[`path`,{d:`M19 15V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V9`,key:`1eqmu1`}],[`path`,{d:`M21 21v-2h-4`,key:`14zm7j`}],[`path`,{d:`M3 5h4V3`,key:`z442eg`}],[`path`,{d:`M7 5a1 1 0 0 1 1 1v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1V3`,key:`ebdjd7`}]]),P=E(`Check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),F=E(`ChevronDown`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]),I=E(`ChevronRight`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),L=E(`Clock3`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`polyline`,{points:`12 6 12 12 16.5 12`,key:`1aq6pp`}]]),ee=E(`Database`,[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 21 19V5`,key:`1wlel7`}],[`path`,{d:`M3 12A9 3 0 0 0 21 12`,key:`mv7ke4`}]]),R=E(`FileJson2`,[[`path`,{d:`M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4`,key:`1pf5j1`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M4 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`fq0c9t`}],[`path`,{d:`M8 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`4gibmv`}]]),te=E(`FileJson`,[[`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`,key:`1rqfz7`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`1oajmo`}],[`path`,{d:`M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`mpwhp6`}]]),ne=E(`Gem`,[[`path`,{d:`M6 3h12l4 6-10 13L2 9Z`,key:`1pcd5k`}],[`path`,{d:`M11 3 8 9l4 13 4-13-3-6`,key:`1fcu3u`}],[`path`,{d:`M2 9h20`,key:`16fsjt`}]]),re=E(`Gift`,[[`rect`,{x:`3`,y:`8`,width:`18`,height:`4`,rx:`1`,key:`bkv52`}],[`path`,{d:`M12 8v13`,key:`1c76mn`}],[`path`,{d:`M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7`,key:`6wjy6b`}],[`path`,{d:`M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5`,key:`1ihvrl`}]]),ie=E(`History`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}],[`path`,{d:`M12 7v5l4 2`,key:`1fdv2h`}]]),ae=E(`KeyRound`,[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]),oe=E(`LayoutDashboard`,[[`rect`,{width:`7`,height:`9`,x:`3`,y:`3`,rx:`1`,key:`10lvy0`}],[`rect`,{width:`7`,height:`5`,x:`14`,y:`3`,rx:`1`,key:`16une8`}],[`rect`,{width:`7`,height:`9`,x:`14`,y:`12`,rx:`1`,key:`1hutg5`}],[`rect`,{width:`7`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`ldoo1y`}]]),se=E(`LogOut`,[[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}],[`polyline`,{points:`16 17 21 12 16 7`,key:`1gabdz`}],[`line`,{x1:`21`,x2:`9`,y1:`12`,y2:`12`,key:`1uyos4`}]]),ce=E(`MessageSquareText`,[[`path`,{d:`M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z`,key:`1lielz`}],[`path`,{d:`M13 8H7`,key:`14i4kc`}],[`path`,{d:`M17 12H7`,key:`16if0g`}]]),le=E(`Pause`,[[`rect`,{x:`14`,y:`4`,width:`4`,height:`16`,rx:`1`,key:`zuxfzm`}],[`rect`,{x:`6`,y:`4`,width:`4`,height:`16`,rx:`1`,key:`1okwgv`}]]),ue=E(`Play`,[[`polygon`,{points:`6 3 20 12 6 21 6 3`,key:`1oa8hb`}]]),z=E(`Plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]),de=E(`RefreshCw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),B=E(`Search`,[[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}],[`path`,{d:`m21 21-4.3-4.3`,key:`1qie3q`}]]),V=E(`Server`,[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]]),fe=E(`ShieldCheck`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),pe=E(`Shield`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]),me=E(`Star`,[[`path`,{d:`M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z`,key:`r04s7s`}]]),he=E(`Trash2`,[[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6`,key:`4alrt4`}],[`path`,{d:`M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2`,key:`v07s0e`}],[`line`,{x1:`10`,x2:`10`,y1:`11`,y2:`17`,key:`1uufr5`}],[`line`,{x1:`14`,x2:`14`,y1:`11`,y2:`17`,key:`xtxkd`}]]),ge=E(`Upload`,[[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`polyline`,{points:`17 8 12 3 7 8`,key:`t8dd8p`}],[`line`,{x1:`12`,x2:`12`,y1:`3`,y2:`15`,key:`widbto`}]]),_e=E(`Users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`path`,{d:`M16 3.13a4 4 0 0 1 0 7.75`,key:`1da9ce`}]]),ve=E(`X`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),ye=o((e=>{var t=u(),n=Symbol.for(`react.element`),r=Symbol.for(`react.fragment`),i=Object.prototype.hasOwnProperty,a=t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,o={key:!0,ref:!0,__self:!0,__source:!0};function s(e,t,r){var s,c={},l=null,u=null;for(s in r!==void 0&&(l=``+r),t.key!==void 0&&(l=``+t.key),t.ref!==void 0&&(u=t.ref),t)i.call(t,s)&&!o.hasOwnProperty(s)&&(c[s]=t[s]);if(e&&e.defaultProps)for(s in t=e.defaultProps,t)c[s]===void 0&&(c[s]=t[s]);return{$$typeof:n,type:e,key:l,ref:u,props:c,_owner:a.current}}e.Fragment=r,e.jsx=s,e.jsxs=s})),H=o(((e,t)=>{t.exports=ye()}))(),U=`telesrv.admin.lang`,be={en:{"app.adminConsole":`Admin Console`,"app.localAccess":`Local access`,"app.title":`telesrv admin`,"common.actions":`Actions`,"common.admins":`Admins`,"common.backToList":`Back to list`,"common.channel":`Channel`,"common.channelOrGroup":`Channel / Group`,"common.clear":`Clear`,"common.close":`Close`,"common.count":`Count`,"common.deleted":`Deleted`,"common.detail":`Details`,"common.device":`Device`,"common.disabled":`Disabled`,"common.enabled":`Enabled`,"common.fromPeer":`From Peer`,"common.group":`Group`,"common.id":`ID`,"common.limit":`Limit`,"common.loading":`Loading`,"common.member":`Member`,"common.members":`Members`,"common.messageId":`Message ID`,"common.name":`Name`,"common.no":`No`,"common.noResults":`No results`,"common.none":`None`,"common.normal":`Normal`,"common.operations":`Operations`,"common.owner":`Owner`,"common.platform":`Platform`,"common.refresh":`Refresh`,"common.search":`Search`,"common.sender":`Sender`,"common.status":`Status`,"common.survived":`Live`,"common.time":`Time`,"common.type":`Type`,"common.updatedAt":`Updated`,"common.username":`Username`,"common.valid":`Valid`,"common.verified":`Verified`,"common.views":`Views`,"common.yes":`Yes`,"route.accounts":`Accounts`,"route.accountsSubtitle":`Console / Accounts`,"route.channels":`Supergroups and Channels`,"route.channelsSubtitle":`Console / Channels`,"route.dashboard":`Operations Console`,"route.dashboardSubtitle":`Console / Overview`,"route.messages":`Message Audit`,"route.messagesSubtitle":`Console / Messages`,"route.gifts":`Star Gifts`,"route.giftsSubtitle":`Console / Star Gifts`,"layout.navigation":`Navigation`,"layout.primaryNav":`Primary navigation`,"layout.dashboard":`Overview`,"layout.accounts":`Accounts`,"layout.channels":`Supergroups / Channels`,"layout.messages":`Messages`,"layout.gifts":`Star Gifts`,"layout.privateMessages":`Private`,"layout.groupMessages":`Groups`,"layout.runtime":`Runtime`,"layout.adminBackend":`Admin backend`,"layout.ready":`Ready`,"layout.pgRead":`PG read`,"layout.readOnly":`Read-only`,"layout.writeOps":`Write operations`,"layout.dryRun":`Dry-run`,"layout.actor":`Actor: {actor}`,"layout.logout":`Log out`,"language.en":`EN`,"language.zh":`中文`,"language.ru":`RU`,"login.heading":`Operations Admin`,"login.body":`Enter credentials to open the console.`,"login.secret":`Admin password or token`,"login.submit":`Log in`,"login.submitting":`Logging in`,"dashboard.eyebrow":`Runtime Overview`,"dashboard.title":`Console Overview`,"dashboard.readPath":`Read path`,"dashboard.readPathValue":`PG read-only`,"dashboard.writePath":`Write path`,"dashboard.executionPolicy":`Execution policy`,"dashboard.dryRunFirst":`Dry-run first`,"dashboard.accountsText":`Account status, premium, verification, sessions.`,"dashboard.channelsText":`Public entities, member counts, verification state.`,"dashboard.messagesText":`Message boxes, updates, outbox state.`,"dashboard.strip.dryRun":`All dangerous actions start with dry-run`,"dashboard.strip.token":`Browser never stores internal tokens`,"dashboard.strip.pagination":`Lists use cursor pagination`,"dashboard.strip.snapshot":`Detail pages retain raw state snapshots`,"account.pageTitle":`Accounts`,"account.queryResults":`Search results`,"account.recentActive":`Recently active accounts`,"account.currentPage":`Accounts on page`,"account.onlineDevices":`Online device records`,"account.premium":`Premium`,"account.frozen":`Frozen`,"account.searchPlaceholder":`User ID / phone / username`,"account.userID":`User ID`,"account.phone":`Phone`,"account.lastActive":`Last active`,"account.notVerified":`Not verified`,"account.notPremium":`Not premium`,"account.premiumUntil":`Premium expires`,"account.starsBalance":`Stars balance`,"account.startingGrantApplied":`initial grant applied`,"account.startingGrantPending":`initial grant pending`,"account.activeSessions":`Authorized devices`,"account.accountFlags":`Account flags`,"account.restriction":`Restriction`,"account.restricted":`Restricted`,"account.createdAt":`Created`,"account.detailTitle":`Account #{id}`,"account.profile":`Account Profile`,"account.loadingDetail":`Loading account detail`,"account.waitingData":`Waiting for data`,"account.noUsername":`No username`,"account.noPhone":`No phone`,"account.accountFrozen":`Account frozen`,"account.accountActive":`Account active`,"account.authorizationsTitle":`Authorized Devices`,"account.authorizationsCount":`{count} authorizations`,"account.recentAdminOps":`Recent Admin Actions`,"account.recent30Audit":`Last 30 audit rows`,"account.actionDock":`Account Actions`,"account.freezeAccount":`Freeze account`,"account.updateFreeze":`Update freeze`,"account.unfreezeAccount":`Unfreeze account`,"account.freezeSince":`Frozen since`,"account.freezeUntil":`Appeal deadline`,"account.freezeUntilAria":`Freeze appeal deadline`,"account.freezeAppealURL":`Appeal URL`,"account.freezeAppealURLAria":`Freeze appeal URL`,"account.premiumMonths":`Premium duration (months)`,"account.premiumMonthsAria":`Set premium duration in months`,"account.setPremium":`Set premium`,"account.clearPremium":`Clear premium`,"account.starsAmount":`Stars to grant`,"account.starsAmountAria":`Set Stars amount to grant`,"account.grantStars":`Grant Stars`,"account.setVerified":`Set verified`,"account.clearVerified":`Clear verified`,"channel.pageTitle":`Supergroups and Channels`,"channel.recentUpdated":`Recently updated`,"channel.currentPage":`Entities on page`,"channel.megagroups":`Supergroups`,"channel.broadcasts":`Channels`,"channel.verifiedCount":`Verified`,"channel.searchPlaceholder":`Channel ID / username / title`,"channel.channelID":`Channel ID`,"channel.kind":`Kind`,"channel.title":`Title`,"channel.pts":`PTS`,"channel.detailProfile":`Channel Profile`,"channel.loadingDetail":`Loading channel detail`,"channel.creator":`Creator {id}`,"channel.governance":`Moderation`,"channel.governanceValue":`Banned {banned} / Kicked {kicked}`,"channel.flags":`Channel flags`,"channel.rawRow":`Channel Raw Row`,"channel.rawRowText":`Database read-only snapshot`,"channel.actionDock":`Channel Actions`,"channel.setVerified":`Set verified`,"channel.clearVerified":`Clear verified`,"channel.kind.broadcast":`Channel`,"channel.kind.forum":`Supergroup / Forum`,"channel.kind.megagroup":`Supergroup`,"channel.kind.generic":`Channel / Group`,"messages.privateTitle":`Private Messages`,"messages.privateEyebrow":`Private message boxes`,"messages.groupTitle":`Group Messages`,"messages.groupEyebrow":`Supergroup / channel messages`,"messages.selectPrivatePeers":`Search and select the owner user and peer user first`,"messages.selectChannel":`Search and select a supergroup or channel first`,"messages.ownerUser":`Owner user`,"messages.peerUser":`Peer user`,"messages.beforeDatePlaceholder":`before_date cursor`,"messages.beforeIDPlaceholder":`before_msg_id cursor`,"messages.limitPlaceholder":`limit <= 100`,"messages.searchMessages":`Search messages`,"messages.nextPage":`Next page`,"messages.currentPage":`Messages on page`,"messages.deleted":`Deleted`,"messages.outgoing":`Outgoing`,"messages.incoming":`Incoming`,"messages.ownerPeer":`Owner / Peer`,"messages.deleteSelected":`Delete selected messages`,"messages.idsPlaceholder":`Message IDs, comma separated`,"messages.revoke":`Revoke for both sides`,"messages.previewDelete":`Dry-run delete`,"messages.clearHistory":`Clear private history`,"messages.maxIDPlaceholder":`max_id cutoff`,"messages.maxBatchesPlaceholder":`max_batches`,"messages.justClear":`Clear only this side`,"messages.previewClearHistory":`Dry-run clear history`,"messages.direction":`Direction`,"messages.body":`Body`,"messages.privateDetailTitle":`Message #{id}`,"messages.detailEyebrow":`Message Detail`,"messages.backPrivate":`Back to private messages`,"messages.backGroup":`Back to group messages`,"messages.ownerPeerTitle":`Owner {owner} · Peer {peer}`,"messages.senderSubtitle":`Sender {sender} · {date}`,"messages.boxID":`Message box ID`,"messages.privateMessageID":`Private message ID`,"messages.messageSender":`Message sender`,"messages.messageBox":`Message Box`,"messages.dialogRow":`Dialog Row`,"messages.privateRow":`Private Message Row`,"messages.channelMessageRow":`Channel Message Row`,"messages.channelRow":`Channel Row`,"messages.userUpdateEvents":`Update Events`,"messages.channelUpdateEvents":`Channel Update Events`,"messages.eventJson":`Event JSON`,"messages.dispatchOutbox":`Dispatch Queue`,"messages.messageBoxesSnapshot":`message_boxes read-only snapshot`,"messages.dialogSnapshot":`dialogs read-only snapshot`,"messages.privateSnapshot":`private_messages read-only snapshot`,"messages.channelMessagesSnapshot":`channel_messages read-only snapshot`,"messages.channelSnapshot":`channels read-only snapshot`,"messages.userEventsSource":`durable user_update_events`,"messages.channelEventsSource":`durable channel_update_events`,"messages.outboxSource":`online/offline dispatch_outbox`,"messages.attempts":`Attempts`,"messages.deleteThis":`Delete this message`,"messages.groupDetailTitle":`Group Message #{id}`,"messages.channelGroupTitle":`Channel / Group {id}`,"messages.mediaCount":`With media`,"messages.channelPosts":`Channel posts`,"messages.channelGroup":`Channel / Group`,"messages.pinned":`Pinned`,"messages.channelPost":`Channel post`,"gifts.pageTitle":`Star Gift Catalog`,"gifts.eyebrow":`Catalog, immutable revisions and animation assets`,"gifts.total":`Catalog entries`,"gifts.enabled":`Enabled`,"gifts.received":`Received gifts`,"gifts.formats":`Accepted formats`,"gifts.add":`Add gift`,"gifts.searchPlaceholder":`Search gift ID, title or format`,"gifts.listSummary":`Showing {shown} of {total}`,"gifts.idRevision":`ID / Revision`,"gifts.price":`Price / Conversion`,"gifts.importTitle":`Import a Star Gift`,"gifts.importEyebrow":`Gift catalog operation`,"gifts.newRevision":`Create revision for gift #{id}`,"gifts.importHint":`Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.`,"gifts.officialSource":`Official snapshot`,"gifts.fileSource":`Upload file`,"gifts.officialHint":`Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically.`,"gifts.officialSearch":`Search official gift ID or title`,"gifts.officialSelect":`Choose an official gift`,"gifts.officialRequired":`Choose an official gift first`,"gifts.officialResults":`Showing {shown} of {total}`,"gifts.officialCategoryLabel":`Official gift capability category`,"gifts.officialCategory.all":`All`,"gifts.officialCategory.upgrade":`Upgradable`,"gifts.officialCategory.craft":`Craftable`,"gifts.officialCategory.basic":`Not upgradable`,"gifts.officialUnnamed":`Unnamed official gift #{id}`,"gifts.officialAttributes":`{count} attributes`,"gifts.canUpgrade":`Can upgrade`,"gifts.cannotUpgrade":`Cannot upgrade`,"gifts.canCraft":`Can Craft`,"gifts.cannotCraft":`Cannot Craft`,"gifts.officialEmpty":`No official gifts match this category and search.`,"gifts.includeCollectible":`Import the complete collectible pool, including crafted models`,"gifts.animation":`Animation file`,"gifts.filePrompt":`Drop or choose a TGS / Lottie file`,"gifts.fileHint":`TGS, JSON or Lottie · validated before import`,"gifts.chooseFile":`Choose file`,"gifts.changeFile":`Change file`,"gifts.title":`Display title`,"gifts.titlePlaceholder":`e.g. Celebration Star`,"gifts.stars":`Price in Stars`,"gifts.convertStars":`Conversion Stars`,"gifts.sortOrder":`Sort order`,"gifts.reason":`Audit reason`,"gifts.reasonPlaceholder":`Briefly describe why this gift is being imported`,"gifts.enableAfterImport":`Enable after import`,"gifts.validate":`Dry-run validation`,"gifts.confirmImport":`Confirm import`,"gifts.stepDetails":`File and details`,"gifts.stepValidate":`Dry-run validation`,"gifts.stepImport":`Confirm import`,"gifts.fileRequired":`Choose a TGS or Lottie file first`,"gifts.source":`Source`,"gifts.replace":`New revision`,"gifts.disable":`Disable`,"gifts.enable":`Enable`,"gifts.empty":`No Star Gifts have been imported.`,"gifts.emptyHint":`Import the first animation above to build the gift catalog.`,"gifts.validationReady":`Validation passed`,"gifts.validationHint":`Review the normalized metadata, then confirm the import.`,"gifts.confirmState":`Apply the validated state change to gift #{id}?`,"collectibles.manage":`Attribute pool`,"collectibles.title":`Collectible pool · Gift #{id}`,"collectibles.eyebrow":`Unique gift attributes`,"collectibles.activeRevision":`Published revision {revision}`,"collectibles.published":`Published`,"collectibles.noPool":`No collectible pool published`,"collectibles.noPoolHint":`Publish models, patterns and backdrops to enable upgrades.`,"collectibles.publishNew":`Publish a new immutable revision`,"collectibles.immutableHint":`Dry-run checks every file and rarity total before the revision becomes active.`,"collectibles.upgradeStars":`Upgrade price in Stars`,"collectibles.supply":`Unique supply`,"collectibles.slug":`Public slug prefix`,"collectibles.models":`Models`,"collectibles.patterns":`Patterns`,"collectibles.backdrops":`Backdrops`,"collectibles.model":`Model`,"collectibles.pattern":`Pattern`,"collectibles.backdrop":`Backdrop`,"collectibles.rarity":`Rarity ‰`,"collectibles.rarityHint":`Permille values are relative regular-upgrade weights; their total does not need to equal 1000.`,"collectibles.colorHint":`Colors are stored as Telegram 24-bit RGB values.`,"collectibles.addAttribute":`Add`,"collectibles.remove":`Remove attribute`,"collectibles.fileRequired":`Every model and pattern needs a TGS or Lottie file.`,"collectibles.backdropID":`Backdrop ID`,"collectibles.color.center":`Center`,"collectibles.color.edge":`Edge`,"collectibles.color.pattern":`Pattern`,"collectibles.color.text":`Text`,"collectibles.validationReady":`Attribute pool is valid`,"collectibles.validationHint":`Review the normalized assets, then publish this immutable revision.`,"collectibles.publish":`Publish revision`,"messages.msgIDsInvalid":`Message IDs are invalid`,"auth.device":`Device`,"auth.platform":`Platform`,"auth.ip":`IP`,"auth.lastActive":`Last active`,"auth.revokeCurrent":`Revoke current`,"auth.keepCurrent":`Keep current`,"auth.revokeAll":`Revoke all devices`,"picker.userPlaceholder":`Search user_id / phone / username`,"picker.channelPlaceholder":`Search channel_id / username / title`,"picker.verified":`Verified`,"picker.regular":`Regular`,"action.reasonRequired":`Please enter an operation reason`,"action.flow":`Action Flow`,"action.close":`Close`,"action.stepReason":`Enter reason`,"action.stepDryRun":`Dry-run check`,"action.stepConfirm":`Confirm execution`,"action.reason":`Operation reason`,"action.reasonPlaceholder":`Describe why this operation is being performed`,"action.requestPreview":`Request preview`,"action.result":`Action result`,"action.commandID":`Command ID`,"action.status":`Status`,"action.dryRun":`Dry-run`,"action.runAgain":`Run dry-run again`,"action.runDry":`Run dry-run first`,"action.confirm":`Confirm execution`,"audit.id":`ID`,"audit.commandID":`Command ID`,"audit.action":`Action`,"audit.actor":`Actor`,"audit.status":`Status`,"audit.dryRun":`Dry-run`,"audit.reason":`Reason`,"audit.time":`Time`},zh:{"app.adminConsole":`管理控制台`,"app.localAccess":`本地访问`,"app.title":`telesrv 管理后台`,"common.actions":`操作`,"common.admins":`管理员`,"common.backToList":`返回列表`,"common.channel":`频道`,"common.channelOrGroup":`频道/群`,"common.clear":`清除`,"common.close":`关闭`,"common.count":`数量`,"common.deleted":`已删除`,"common.detail":`详情`,"common.device":`设备`,"common.disabled":`已禁用`,"common.enabled":`已启用`,"common.fromPeer":`From Peer`,"common.group":`群组`,"common.id":`ID`,"common.limit":`条数`,"common.loading":`加载中`,"common.member":`成员`,"common.members":`成员`,"common.messageId":`消息 ID`,"common.name":`姓名`,"common.no":`否`,"common.noResults":`无结果`,"common.none":`无`,"common.normal":`正常`,"common.operations":`操作`,"common.owner":`所属`,"common.platform":`平台`,"common.refresh":`刷新`,"common.search":`查询`,"common.sender":`发送方`,"common.status":`状态`,"common.survived":`存活`,"common.time":`时间`,"common.type":`类型`,"common.updatedAt":`更新时间`,"common.username":`用户名`,"common.valid":`有效`,"common.verified":`已认证`,"common.views":`浏览`,"common.yes":`是`,"route.accounts":`账号管理`,"route.accountsSubtitle":`控制台 / 账号`,"route.channels":`超级群与频道`,"route.channelsSubtitle":`控制台 / 频道`,"route.dashboard":`运维控制台`,"route.dashboardSubtitle":`控制台 / 总览`,"route.messages":`消息审计`,"route.messagesSubtitle":`控制台 / 消息`,"route.gifts":`星星礼物`,"route.giftsSubtitle":`控制台 / 星星礼物`,"layout.navigation":`导航`,"layout.primaryNav":`主导航`,"layout.dashboard":`总览`,"layout.accounts":`账号`,"layout.channels":`超级群/频道`,"layout.messages":`消息`,"layout.gifts":`礼物目录`,"layout.privateMessages":`私聊`,"layout.groupMessages":`群聊`,"layout.runtime":`运行状态`,"layout.adminBackend":`管理后台`,"layout.ready":`就绪`,"layout.pgRead":`PG 读取`,"layout.readOnly":`只读`,"layout.writeOps":`写操作`,"layout.dryRun":`预演`,"layout.actor":`操作者:{actor}`,"layout.logout":`退出`,"language.en":`EN`,"language.zh":`中文`,"language.ru":`RU`,"login.heading":`运维后台`,"login.body":`输入凭据后进入控制台。`,"login.secret":`管理员密码或 token`,"login.submit":`登录`,"login.submitting":`登录中`,"dashboard.eyebrow":`运行总览`,"dashboard.title":`控制台总览`,"dashboard.readPath":`读路径`,"dashboard.readPathValue":`PG 只读`,"dashboard.writePath":`写路径`,"dashboard.executionPolicy":`执行策略`,"dashboard.dryRunFirst":`先预演`,"dashboard.accountsText":`账号状态、会员、认证、会话。`,"dashboard.channelsText":`公开实体、成员计数、认证状态。`,"dashboard.messagesText":`消息盒、update、outbox 状态。`,"dashboard.strip.dryRun":`所有危险操作先预演`,"dashboard.strip.token":`浏览器不持有内部 token`,"dashboard.strip.pagination":`列表使用游标分页`,"dashboard.strip.snapshot":`详情页保留原始状态快照`,"account.pageTitle":`账号`,"account.queryResults":`查询结果`,"account.recentActive":`最近活跃账号`,"account.currentPage":`当前页账号`,"account.onlineDevices":`在线设备记录`,"account.premium":`会员`,"account.frozen":`冻结`,"account.searchPlaceholder":`用户 ID / 手机号 / 用户名`,"account.userID":`用户 ID`,"account.phone":`手机号`,"account.lastActive":`最近活跃`,"account.notVerified":`未认证`,"account.notPremium":`非会员`,"account.premiumUntil":`会员到期`,"account.starsBalance":`Stars 余额`,"account.startingGrantApplied":`初始赠送已发放`,"account.startingGrantPending":`初始赠送未触发`,"account.activeSessions":`授权设备`,"account.accountFlags":`账号标记`,"account.restriction":`限制状态`,"account.restricted":`已限制`,"account.createdAt":`创建时间`,"account.detailTitle":`账号 #{id}`,"account.profile":`账号档案`,"account.loadingDetail":`加载账号详情`,"account.waitingData":`等待数据`,"account.noUsername":`无用户名`,"account.noPhone":`无手机号`,"account.accountFrozen":`账号已冻结`,"account.accountActive":`账号正常`,"account.authorizationsTitle":`授权设备`,"account.authorizationsCount":`共 {count} 个授权`,"account.recentAdminOps":`最近后台操作`,"account.recent30Audit":`最近 30 条审计`,"account.actionDock":`账号操作`,"account.freezeAccount":`冻结账号`,"account.updateFreeze":`更新冻结信息`,"account.unfreezeAccount":`解冻账号`,"account.freezeSince":`冻结开始时间`,"account.freezeUntil":`申诉截止时间`,"account.freezeUntilAria":`账号冻结申诉截止时间`,"account.freezeAppealURL":`申诉链接`,"account.freezeAppealURLAria":`账号冻结申诉链接`,"account.premiumMonths":`会员时长(月)`,"account.premiumMonthsAria":`设置会员时长,单位月`,"account.setPremium":`设置会员`,"account.clearPremium":`取消会员`,"account.starsAmount":`赠送 Stars 数量`,"account.starsAmountAria":`设置要赠送的 Stars 数量`,"account.grantStars":`赠送 Stars`,"account.setVerified":`设置认证`,"account.clearVerified":`取消认证`,"channel.pageTitle":`超级群与频道`,"channel.recentUpdated":`最近更新`,"channel.currentPage":`当前页实体`,"channel.megagroups":`超级群`,"channel.broadcasts":`频道`,"channel.verifiedCount":`已认证`,"channel.searchPlaceholder":`频道 ID / 用户名 / 标题`,"channel.channelID":`频道 ID`,"channel.kind":`类型`,"channel.title":`标题`,"channel.pts":`PTS`,"channel.detailProfile":`频道档案`,"channel.loadingDetail":`加载频道详情`,"channel.creator":`创建者 {id}`,"channel.governance":`治理状态`,"channel.governanceValue":`封禁 {banned} / 踢出 {kicked}`,"channel.flags":`频道标记`,"channel.rawRow":`频道原始行`,"channel.rawRowText":`数据库只读快照`,"channel.actionDock":`频道操作`,"channel.setVerified":`设置认证`,"channel.clearVerified":`取消认证`,"channel.kind.broadcast":`频道`,"channel.kind.forum":`超级群/论坛`,"channel.kind.megagroup":`超级群`,"channel.kind.generic":`频道/群`,"messages.privateTitle":`私聊消息`,"messages.privateEyebrow":`私聊消息盒`,"messages.groupTitle":`群聊消息`,"messages.groupEyebrow":`超级群 / 频道消息`,"messages.selectPrivatePeers":`请先搜索并选择所属用户和对端用户`,"messages.selectChannel":`请先搜索并选择超级群或频道`,"messages.ownerUser":`所属用户`,"messages.peerUser":`对端用户`,"messages.beforeDatePlaceholder":`before_date 游标`,"messages.beforeIDPlaceholder":`before_msg_id 游标`,"messages.limitPlaceholder":`条数 <= 100`,"messages.searchMessages":`查询消息`,"messages.nextPage":`下一页`,"messages.currentPage":`当前页消息`,"messages.deleted":`已删除`,"messages.outgoing":`发出消息`,"messages.incoming":`收到`,"messages.ownerPeer":`所属 / 对端`,"messages.deleteSelected":`删除指定消息`,"messages.idsPlaceholder":`消息 ID,逗号分隔`,"messages.revoke":`同步撤回`,"messages.previewDelete":`预演删除`,"messages.clearHistory":`清空私聊历史`,"messages.maxIDPlaceholder":`max_id 截止消息`,"messages.maxBatchesPlaceholder":`max_batches 批次数`,"messages.justClear":`仅清本侧`,"messages.previewClearHistory":`预演清历史`,"messages.direction":`方向`,"messages.body":`正文`,"messages.privateDetailTitle":`消息 #{id}`,"messages.detailEyebrow":`消息详情`,"messages.backPrivate":`返回私聊消息`,"messages.backGroup":`返回群聊消息`,"messages.ownerPeerTitle":`所属 {owner} · 对端 {peer}`,"messages.senderSubtitle":`发送方 {sender} · {date}`,"messages.boxID":`消息盒 ID`,"messages.privateMessageID":`私聊消息 ID`,"messages.messageSender":`发送方`,"messages.messageBox":`消息盒`,"messages.dialogRow":`会话行`,"messages.privateRow":`私聊消息行`,"messages.channelMessageRow":`消息行`,"messages.channelRow":`频道行`,"messages.userUpdateEvents":`更新事件`,"messages.channelUpdateEvents":`频道更新事件`,"messages.eventJson":`事件 JSON`,"messages.dispatchOutbox":`分发队列`,"messages.messageBoxesSnapshot":`message_boxes 只读快照`,"messages.dialogSnapshot":`dialogs 只读快照`,"messages.privateSnapshot":`private_messages 只读快照`,"messages.channelMessagesSnapshot":`channel_messages 只读快照`,"messages.channelSnapshot":`channels 只读快照`,"messages.userEventsSource":`durable user_update_events`,"messages.channelEventsSource":`durable channel_update_events`,"messages.outboxSource":`在线/离线 dispatch_outbox`,"messages.attempts":`尝试`,"messages.deleteThis":`删除此消息`,"messages.groupDetailTitle":`群聊消息 #{id}`,"messages.channelGroupTitle":`频道/群 {id}`,"messages.mediaCount":`有媒体`,"messages.channelPosts":`频道帖子`,"messages.channelGroup":`频道 / 群`,"messages.pinned":`置顶`,"messages.channelPost":`频道帖子`,"gifts.pageTitle":`星星礼物目录`,"gifts.eyebrow":`目录、不可变版本与动画资源`,"gifts.total":`目录条目`,"gifts.enabled":`已启用`,"gifts.received":`已领取礼物`,"gifts.formats":`支持格式`,"gifts.add":`添加礼物`,"gifts.searchPlaceholder":`搜索礼物 ID、标题或格式`,"gifts.listSummary":`显示 {shown} / {total} 项`,"gifts.idRevision":`ID / 版本`,"gifts.price":`售价 / 兑换`,"gifts.importTitle":`导入星星礼物`,"gifts.importEyebrow":`礼物目录操作`,"gifts.newRevision":`为礼物 #{id} 创建新版本`,"gifts.importHint":`支持 TGS 或纯 Lottie JSON;Lottie 会规范化并压缩成 TGS。`,"gifts.officialSource":`官方资源库`,"gifts.fileSource":`上传文件`,"gifts.officialHint":`从 data/official-gifts 的已校验快照中选择;完整 collectible 属性池会与礼物原子导入。`,"gifts.officialSearch":`搜索官方礼物 ID 或标题`,"gifts.officialSelect":`请选择官方礼物`,"gifts.officialRequired":`请先选择一个官方礼物`,"gifts.officialResults":`显示 {shown} / {total} 项`,"gifts.officialCategoryLabel":`官方礼物能力分类`,"gifts.officialCategory.all":`全部`,"gifts.officialCategory.upgrade":`可升级`,"gifts.officialCategory.craft":`可 Craft`,"gifts.officialCategory.basic":`不可升级`,"gifts.officialUnnamed":`未命名官方礼物 #{id}`,"gifts.officialAttributes":`{count} 个属性`,"gifts.canUpgrade":`可升级`,"gifts.cannotUpgrade":`不可升级`,"gifts.canCraft":`可 Craft`,"gifts.cannotCraft":`不可 Craft`,"gifts.officialEmpty":`当前分类和搜索条件下没有官方礼物。`,"gifts.includeCollectible":`完整导入 collectible 属性池(包含 crafted 模型)`,"gifts.animation":`动画文件`,"gifts.filePrompt":`拖放或选择 TGS / Lottie 文件`,"gifts.fileHint":`支持 TGS、JSON、Lottie,导入前会先进行校验`,"gifts.chooseFile":`选择文件`,"gifts.changeFile":`更换文件`,"gifts.title":`显示标题`,"gifts.titlePlaceholder":`例如:庆典星星`,"gifts.stars":`售价 Stars`,"gifts.convertStars":`可兑换 Stars`,"gifts.sortOrder":`排序值`,"gifts.reason":`审计原因`,"gifts.reasonPlaceholder":`简要说明本次导入礼物的原因`,"gifts.enableAfterImport":`导入后启用`,"gifts.validate":`Dry-run 校验`,"gifts.confirmImport":`确认导入`,"gifts.stepDetails":`文件与信息`,"gifts.stepValidate":`Dry-run 校验`,"gifts.stepImport":`确认导入`,"gifts.fileRequired":`请先选择 TGS 或 Lottie 文件`,"gifts.source":`来源`,"gifts.replace":`创建新版本`,"gifts.disable":`停用`,"gifts.enable":`启用`,"gifts.empty":`尚未导入星星礼物。`,"gifts.emptyHint":`从上方导入第一个动画,开始搭建礼物目录。`,"gifts.validationReady":`校验已通过`,"gifts.validationHint":`确认规范化后的元数据无误,再执行正式导入。`,"gifts.confirmState":`确认执行礼物 #{id} 的状态变更吗?`,"collectibles.manage":`属性池`,"collectibles.title":`Collectibles 属性池 · 礼物 #{id}`,"collectibles.eyebrow":`唯一礼物属性管理`,"collectibles.activeRevision":`已发布版本 {revision}`,"collectibles.published":`已发布`,"collectibles.noPool":`尚未发布 Collectibles 属性池`,"collectibles.noPoolHint":`发布模型、图案与背景后,客户端即可升级为唯一礼物。`,"collectibles.publishNew":`发布新的不可变版本`,"collectibles.immutableHint":`Dry-run 会校验全部文件和稀有度总和,通过后才切换为当前版本。`,"collectibles.upgradeStars":`升级价格 Stars`,"collectibles.supply":`唯一礼物总量`,"collectibles.slug":`公开 Slug 前缀`,"collectibles.models":`模型`,"collectibles.patterns":`图案`,"collectibles.backdrops":`背景`,"collectibles.model":`模型`,"collectibles.pattern":`图案`,"collectibles.backdrop":`背景`,"collectibles.rarity":`稀有度 ‰`,"collectibles.rarityHint":`Permille 是普通升级的相对权重,不要求每类合计正好为 1000。`,"collectibles.colorHint":`颜色会按 Telegram 24 位 RGB 数值保存。`,"collectibles.addAttribute":`添加`,"collectibles.remove":`删除属性`,"collectibles.fileRequired":`每个模型和图案都必须选择 TGS 或 Lottie 文件。`,"collectibles.backdropID":`背景 ID`,"collectibles.color.center":`中心色`,"collectibles.color.edge":`边缘色`,"collectibles.color.pattern":`图案色`,"collectibles.color.text":`文字色`,"collectibles.validationReady":`属性池校验通过`,"collectibles.validationHint":`确认规范化资源无误后,即可发布这个不可变版本。`,"collectibles.publish":`发布版本`,"messages.msgIDsInvalid":`消息 ID 无效`,"auth.device":`设备`,"auth.platform":`平台`,"auth.ip":`IP`,"auth.lastActive":`最近活跃`,"auth.revokeCurrent":`撤销当前`,"auth.keepCurrent":`保留当前`,"auth.revokeAll":`撤销全部设备`,"picker.userPlaceholder":`搜索 user_id / phone / username`,"picker.channelPlaceholder":`搜索 channel_id / username / title`,"picker.verified":`认证`,"picker.regular":`普通`,"action.reasonRequired":`请填写操作原因`,"action.flow":`操作流程`,"action.close":`关闭`,"action.stepReason":`填写原因`,"action.stepDryRun":`预演检查`,"action.stepConfirm":`确认执行`,"action.reason":`操作原因`,"action.reasonPlaceholder":`说明本次操作原因`,"action.requestPreview":`请求预览`,"action.result":`操作结果`,"action.commandID":`命令 ID`,"action.status":`状态`,"action.dryRun":`预演`,"action.runAgain":`重新预演`,"action.runDry":`先预演`,"action.confirm":`确认执行`,"audit.id":`ID`,"audit.commandID":`命令 ID`,"audit.action":`动作`,"audit.actor":`操作者`,"audit.status":`状态`,"audit.dryRun":`预演`,"audit.reason":`原因`,"audit.time":`时间`},ru:{"app.adminConsole":`Панель администратора`,"app.localAccess":`Локальный доступ`,"app.title":`telesrv admin`,"common.actions":`Действия`,"common.admins":`Администраторы`,"common.backToList":`Назад к списку`,"common.channel":`Канал`,"common.channelOrGroup":`Канал / Группа`,"common.clear":`Очистить`,"common.close":`Закрыть`,"common.count":`Количество`,"common.deleted":`Удалено`,"common.detail":`Детали`,"common.device":`Устройство`,"common.disabled":`Отключено`,"common.enabled":`Включено`,"common.fromPeer":`От пира`,"common.group":`Группа`,"common.id":`ID`,"common.limit":`Лимит`,"common.loading":`Загрузка...`,"common.member":`Участник`,"common.members":`Участники`,"common.messageId":`ID сообщения`,"common.name":`Имя`,"common.no":`Нет`,"common.noResults":`Нет результатов`,"common.none":`Нет`,"common.normal":`Обычный`,"common.operations":`Операции`,"common.owner":`Владелец`,"common.platform":`Платформа`,"common.refresh":`Обновить`,"common.search":`Поиск`,"common.sender":`Отправитель`,"common.status":`Статус`,"common.survived":`Уцелело`,"common.time":`Время`,"common.type":`Тип`,"common.updatedAt":`Обновлено`,"common.username":`Имя пользователя`,"common.valid":`Действителен`,"common.verified":`Подтвержден`,"common.views":`Просмотры`,"common.yes":`Да`,"route.accounts":`Аккаунты`,"route.accountsSubtitle":`Консоль / Аккаунты`,"route.channels":`Супергруппы и каналы`,"route.channelsSubtitle":`Консоль / Каналы`,"route.dashboard":`Панель управления`,"route.dashboardSubtitle":`Консоль / Обзор`,"route.messages":`Аудит сообщений`,"route.messagesSubtitle":`Консоль / Сообщения`,"route.gifts":`Звездные подарки`,"route.giftsSubtitle":`Консоль / Звездные подарки`,"layout.navigation":`Навигация`,"layout.primaryNav":`Основное меню`,"layout.dashboard":`Обзор`,"layout.accounts":`Аккаунты`,"layout.channels":`Супергруппы / Каналы`,"layout.messages":`Сообщения`,"layout.gifts":`Звездные подарки`,"layout.privateMessages":`Личные`,"layout.groupMessages":`Группы`,"layout.runtime":`Среда выполнения`,"layout.adminBackend":`Админ-бэкенд`,"layout.ready":`Готов`,"layout.pgRead":`Чтение из PG`,"layout.readOnly":`Только чтение`,"layout.writeOps":`Операции записи`,"layout.dryRun":`Тестовый запуск`,"layout.actor":`Вход выполнен как: {actor}`,"layout.logout":`Выйти`,"language.en":`EN`,"language.zh":`中文`,"language.ru":`RU`,"login.heading":`Панель администратора`,"login.body":`Введите учетные данные для входа в консоль.`,"login.secret":`Пароль или токен администратора`,"login.submit":`Войти`,"login.submitting":`Вход...`,"dashboard.eyebrow":`Состояние системы`,"dashboard.title":`Обзор консоли`,"dashboard.readPath":`Путь чтения`,"dashboard.readPathValue":`PG только для чтения`,"dashboard.writePath":`Путь записи`,"dashboard.executionPolicy":`Политика выполнения`,"dashboard.dryRunFirst":`Сначала тестовый запуск`,"dashboard.accountsText":`Статус аккаунтов, премиум, верификация, сессии.`,"dashboard.channelsText":`Публичные каналы и группы, количество участников, статус верификации.`,"dashboard.messagesText":`Ящики сообщений, обновления, состояние исходящих.`,"dashboard.strip.dryRun":`Все опасные действия начинаются с тестового запуска`,"dashboard.strip.token":`Браузер никогда не сохраняет внутренние токены`,"dashboard.strip.pagination":`Списки используют курсорную пагинацию`,"dashboard.strip.snapshot":`Детальные страницы сохраняют моментальные снимки исходного состояния`,"account.pageTitle":`Аккаунты`,"account.queryResults":`Результаты поиска`,"account.recentActive":`Недавно активные аккаунты`,"account.currentPage":`Аккаунты на странице`,"account.onlineDevices":`Активные сессии устройств`,"account.premium":`Premium`,"account.frozen":`Заморожен`,"account.searchPlaceholder":`ID пользователя / телефон / имя пользователя`,"account.userID":`ID пользователя`,"account.phone":`Телефон`,"account.lastActive":`Последняя активность`,"account.notVerified":`Не подтвержден`,"account.notPremium":`Без Premium`,"account.premiumUntil":`Premium истекает`,"account.starsBalance":`Баланс Звезд`,"account.startingGrantApplied":`стартовый бонус начислен`,"account.startingGrantPending":`ожидает стартового бонуса`,"account.activeSessions":`Авторизованные устройства`,"account.accountFlags":`Флаги аккаунта`,"account.restriction":`Ограничение`,"account.restricted":`Ограничен`,"account.createdAt":`Создан`,"account.detailTitle":`Аккаунт #{id}`,"account.profile":`Профиль аккаунта`,"account.loadingDetail":`Загрузка данных аккаунта`,"account.waitingData":`Ожидание данных`,"account.noUsername":`Нет имени пользователя`,"account.noPhone":`Нет телефона`,"account.accountFrozen":`Аккаунт заморожен`,"account.accountActive":`Аккаунт активен`,"account.authorizationsTitle":`Авторизованные устройства`,"account.authorizationsCount":`Авторизаций: {count}`,"account.recentAdminOps":`Последние действия администратора`,"account.recent30Audit":`Последние 30 записей аудита`,"account.actionDock":`Действия с аккаунтом`,"account.freezeAccount":`Заморозить аккаунт`,"account.updateFreeze":`Обновить параметры заморозки`,"account.unfreezeAccount":`Разморозить аккаунт`,"account.freezeSince":`Заморожен с`,"account.freezeUntil":`Срок подачи апелляции`,"account.freezeUntilAria":`Срок подачи апелляции на заморозку`,"account.freezeAppealURL":`URL для апелляции`,"account.freezeAppealURLAria":`URL для апелляции на заморозку`,"account.premiumMonths":`Срок действия Premium (в месяцах)`,"account.premiumMonthsAria":`Указать срок действия Premium в месяцах`,"account.setPremium":`Выдать Premium`,"account.clearPremium":`Снять Premium`,"account.starsAmount":`Количество звёзд`,"account.starsAmountAria":`Указать количество начисляемых звёзд`,"account.grantStars":`Начислить звёзды`,"account.setVerified":`Подтвердить аккаунт`,"account.clearVerified":`Снять подтверждение`,"channel.pageTitle":`Супергруппы и каналы`,"channel.recentUpdated":`Недавно обновленные`,"channel.currentPage":`Объекты на странице`,"channel.megagroups":`Супергруппы`,"channel.broadcasts":`Каналы`,"channel.verifiedCount":`Подтверждено`,"channel.searchPlaceholder":`ID канала / имя пользователя / название`,"channel.channelID":`ID канала`,"channel.kind":`Тип`,"channel.title":`Название`,"channel.pts":`PTS`,"channel.detailProfile":`Профиль канала`,"channel.loadingDetail":`Загрузка данных канала`,"channel.creator":`Создатель: {id}`,"channel.governance":`Модерация`,"channel.governanceValue":`Заблокировано {banned} / Исключено {kicked}`,"channel.flags":`Флаги канала`,"channel.rawRow":`Исходная строка БД`,"channel.rawRowText":`Снимок базы данных только для чтения`,"channel.actionDock":`Действия с каналом`,"channel.setVerified":`Подтвердить канал`,"channel.clearVerified":`Снять подтверждение`,"channel.kind.broadcast":`Канал`,"channel.kind.forum":`Супергруппа / Форум`,"channel.kind.megagroup":`Супергруппа`,"channel.kind.generic":`Канал / Группа`,"messages.privateTitle":`Личные сообщения`,"messages.privateEyebrow":`Личные ящики сообщений`,"messages.groupTitle":`Групповые сообщения`,"messages.groupEyebrow":`Сообщения супергрупп и каналов`,"messages.selectPrivatePeers":`Сначала найдите и выберите владельца и собеседника`,"messages.selectChannel":`Сначала найдите и выберите супергруппу или канал`,"messages.ownerUser":`Пользователь-владелец`,"messages.peerUser":`Собеседник`,"messages.beforeDatePlaceholder":`курсор before_date`,"messages.beforeIDPlaceholder":`курсор before_msg_id`,"messages.limitPlaceholder":`лимит <= 100`,"messages.searchMessages":`Поиск сообщений`,"messages.nextPage":`Следующая страница`,"messages.currentPage":`Сообщения на странице`,"messages.deleted":`Удалено`,"messages.outgoing":`Исходящее`,"messages.incoming":`Входящее`,"messages.ownerPeer":`Владелец / Собеседник`,"messages.deleteSelected":`Указать и удалить выбранные сообщения`,"messages.idsPlaceholder":`ID сообщений через запятую`,"messages.revoke":`Удалить для обеих сторон`,"messages.previewDelete":`Тестовое удаление`,"messages.clearHistory":`Очистить историю личной переписки`,"messages.maxIDPlaceholder":`граница max_id`,"messages.maxBatchesPlaceholder":`max_batches`,"messages.justClear":`Очистить только у себя`,"messages.previewClearHistory":`Тестовая очистка истории`,"messages.direction":`Направление`,"messages.body":`Текст сообщения`,"messages.privateDetailTitle":`Сообщение #{id}`,"messages.detailEyebrow":`Детали сообщения`,"messages.backPrivate":`Назад к личным сообщениям`,"messages.backGroup":`Назад к групповым сообщениям`,"messages.ownerPeerTitle":`Владелец {owner} · Собеседник {peer}`,"messages.senderSubtitle":`Отправитель {sender} · {date}`,"messages.boxID":`ID ящика сообщений`,"messages.privateMessageID":`ID личного сообщения`,"messages.messageSender":`Отправитель сообщения`,"messages.messageBox":`Ящик сообщений`,"messages.dialogRow":`Строка диалога`,"messages.privateRow":`Строка личного сообщения`,"messages.channelMessageRow":`Строка сообщения канала`,"messages.channelRow":`Строка канала`,"messages.userUpdateEvents":`События обновления пользователей`,"messages.channelUpdateEvents":`События обновления каналов`,"messages.eventJson":`JSON события`,"messages.dispatchOutbox":`Очередь отправки (Outbox)`,"messages.messageBoxesSnapshot":`Снимок message_boxes только для чтения`,"messages.dialogSnapshot":`Снимок dialogs только для чтения`,"messages.privateSnapshot":`Снимок private_messages только для чтения`,"messages.channelMessagesSnapshot":`Снимок channel_messages только для чтения`,"messages.channelSnapshot":`Снимок channels только для чтения`,"messages.userEventsSource":`постоянные user_update_events`,"messages.channelEventsSource":`постоянные channel_update_events`,"messages.outboxSource":`онлайн/офлайн dispatch_outbox`,"messages.attempts":`Попытки`,"messages.deleteThis":`Удалить это сообщение`,"messages.groupDetailTitle":`Групповое сообщение #{id}`,"messages.channelGroupTitle":`Канал / Группа {id}`,"messages.mediaCount":`С медиафайлами`,"messages.channelPosts":`Посты канала`,"messages.channelGroup":`Канал / Группа`,"messages.pinned":`Закреплено`,"messages.channelPost":`Пост в канале`,"gifts.pageTitle":`Каталог звездных подарков`,"gifts.eyebrow":`Каталог, неизменяемые версии и файлы анимаций`,"gifts.total":`Подарков в каталоге`,"gifts.enabled":`Включено`,"gifts.received":`Полученные подарки`,"gifts.formats":`Поддерживаемые форматы`,"gifts.add":`Добавить подарок`,"gifts.searchPlaceholder":`Поиск по ID подарка, названию или формату`,"gifts.listSummary":`Показано {shown} из {total}`,"gifts.idRevision":`ID / Версия`,"gifts.price":`Цена / Конвертация`,"gifts.importTitle":`Импорт звездного подарка`,"gifts.importEyebrow":`Управление каталогом подарков`,"gifts.newRevision":`Создать версию для подарка #{id}`,"gifts.importHint":`Загрузите файл TGS или обычный Lottie JSON. Lottie нормализуется и сжимается в формат TGS.`,"gifts.officialSource":`Официальный снимок`,"gifts.fileSource":`Загрузить файл`,"gifts.officialHint":`Выберите проверенный подарок из data/official-gifts. Полные пулы коллекционных предметов импортируются атомарно.`,"gifts.officialSearch":`Поиск по ID или названию официального подарка`,"gifts.officialSelect":`Выберите официальный подарок`,"gifts.officialRequired":`Сначала выберите официальный подарок`,"gifts.officialResults":`Показано {shown} из {total}`,"gifts.officialCategoryLabel":`Категория возможностей официального подарка`,"gifts.officialCategory.all":`Все`,"gifts.officialCategory.upgrade":`Можно улучшить`,"gifts.officialCategory.craft":`Можно создать`,"gifts.officialCategory.basic":`Нельзя улучшить`,"gifts.officialUnnamed":`Официальный подарок без названия #{id}`,"gifts.officialAttributes":`Атрибутов: {count}`,"gifts.canUpgrade":`Можно улучшить`,"gifts.cannotUpgrade":`Нельзя улучшить`,"gifts.canCraft":`Можно создать`,"gifts.cannotCraft":`Нельзя создать`,"gifts.officialEmpty":`Нет подарков, соответствующих категории и поиску.`,"gifts.includeCollectible":`Импортировать полный пул коллекционных предметов, включая созданные модели`,"gifts.animation":`Файл анимации`,"gifts.filePrompt":`Перетащите или выберите файл TGS / Lottie`,"gifts.fileHint":`TGS, JSON или Lottie · файл проверяется перед импортом`,"gifts.chooseFile":`Выбрать файл`,"gifts.changeFile":`Изменить файл`,"gifts.title":`Отображаемое название`,"gifts.titlePlaceholder":`например, Праздничная звезда`,"gifts.stars":`Цена в Звездах`,"gifts.convertStars":`Звезд при конвертации`,"gifts.sortOrder":`Порядок сортировки`,"gifts.reason":`Причина для аудита`,"gifts.reasonPlaceholder":`Кратко опишите причину импорта этого подарка`,"gifts.enableAfterImport":`Включить после импорта`,"gifts.validate":`Тестовая проверка`,"gifts.confirmImport":`Подтвердить импорт`,"gifts.stepDetails":`Файл и описание`,"gifts.stepValidate":`Тестовая проверка`,"gifts.stepImport":`Подтверждение импорта`,"gifts.fileRequired":`Сначала выберите файл TGS или Lottie`,"gifts.source":`Источник`,"gifts.replace":`Новая версия`,"gifts.disable":`Отключить`,"gifts.enable":`Включить`,"gifts.empty":`Звездные подарки еще не импортированы.`,"gifts.emptyHint":`Импортируйте первую анимацию, чтобы начать наполнение каталога.`,"gifts.validationReady":`Проверка пройдена`,"gifts.validationHint":`Проверьте нормализованные метаданные и подтвердите импорт.`,"gifts.confirmState":`Применить проверенные изменения состояния к подарку #{id}?`,"collectibles.manage":`Пул атрибутов`,"collectibles.title":`Пул коллекционных предметов · Подарок #{id}`,"collectibles.eyebrow":`Уникальные атрибуты подарка`,"collectibles.activeRevision":`Опубликованная версия {revision}`,"collectibles.published":`Опубликовано`,"collectibles.noPool":`Нет опубликованного пула коллекционных предметов`,"collectibles.noPoolHint":`Опубликуйте модели, узоры и фоны для активации улучшений.`,"collectibles.publishNew":`Опубликовать новую неизменяемую версию`,"collectibles.immutableHint":`Тестовый запуск проверяет каждый файл и итоговые показатели редкости перед тем, как версия станет активной.`,"collectibles.upgradeStars":`Цена улучшения в Звездах`,"collectibles.supply":`Уникальный тираж`,"collectibles.slug":`Публичный префикс ссылки (slug)`,"collectibles.models":`Модели`,"collectibles.patterns":`Узоры`,"collectibles.backdrops":`Фоны`,"collectibles.model":`Модель`,"collectibles.pattern":`Узор`,"collectibles.backdrop":`Фон`,"collectibles.rarity":`Редкость ‰`,"collectibles.rarityHint":`Значения permille — это относительные веса обычного улучшения; их сумма не обязана равняться 1000.`,"collectibles.colorHint":`Цвета сохраняются как 24-битные RGB-значения Telegram.`,"collectibles.addAttribute":`Добавить`,"collectibles.remove":`Удалить атрибут`,"collectibles.fileRequired":`Для каждой модели и узора требуется файл TGS или Lottie.`,"collectibles.backdropID":`ID фона`,"collectibles.color.center":`Центр`,"collectibles.color.edge":`Край`,"collectibles.color.pattern":`Узор`,"collectibles.color.text":`Текст`,"collectibles.validationReady":`Пул атрибутов корректен`,"collectibles.validationHint":`Проверьте нормализованные ресурсы и опубликуйте эту неизменяемую версию.`,"collectibles.publish":`Опубликовать версию`,"messages.msgIDsInvalid":`Некорректные ID сообщений`,"auth.device":`Устройство`,"auth.platform":`Платформа`,"auth.ip":`IP-адрес`,"auth.lastActive":`Последняя активность`,"auth.revokeCurrent":`Отозвать текущую`,"auth.keepCurrent":`Оставить текущую`,"auth.revokeAll":`Разлогинить все устройства`,"picker.userPlaceholder":`Поиск по user_id / телефону / имени пользователя`,"picker.channelPlaceholder":`Поиск по channel_id / имени пользователя / названию`,"picker.verified":`Подтвержденные`,"picker.regular":`Обычные`,"action.reasonRequired":`Пожалуйста, укажите причину операции`,"action.flow":`Процесс выполнения`,"action.close":`Закрыть`,"action.stepReason":`Укажите причину`,"action.stepDryRun":`Тестовый запуск`,"action.stepConfirm":`Подтверждение выполнения`,"action.reason":`Причина операции`,"action.reasonPlaceholder":`Опишите, почему выполняется эта операция`,"action.requestPreview":`Запросить предпросмотр`,"action.result":`Результат действия`,"action.commandID":`ID команды`,"action.status":`Статус`,"action.dryRun":`Тестовый запуск`,"action.runAgain":`Запустить тестовый запуск снова`,"action.runDry":`Сначала выполните тестовый запуск`,"action.confirm":`Подтвердить выполнение`,"audit.id":`ID`,"audit.commandID":`ID команды`,"audit.action":`Действие`,"audit.actor":`Исполнитель`,"audit.status":`Статус`,"audit.dryRun":`Тестовый запуск`,"audit.reason":`Причина`,"audit.time":`Время`}},xe=(0,g.createContext)(null);function Se({children:e}){let[t,n]=(0,g.useState)(()=>Ee());(0,g.useEffect)(()=>{try{localStorage.setItem(U,t)}catch{}let e=t===`zh`?`zh-CN`:t===`ru`?`ru`:`en`;document.documentElement.lang=e,document.documentElement.dir=`ltr`,document.documentElement.setAttribute(`translate`,`no`),document.body.classList.add(`notranslate`),document.title=Te(t,`app.title`)},[t]);let r=(0,g.useMemo)(()=>({lang:t,setLang:n,t:(e,n)=>Te(t,e,n)}),[t]);return(0,H.jsx)(xe.Provider,{value:r,children:e})}function Ce(){let e=(0,g.useContext)(xe);if(!e)throw Error(`useI18n must be used inside I18nProvider`);return e}function we(){let{lang:e,setLang:t,t:n}=Ce();return(0,H.jsx)(`div`,{className:`language-switch`,role:`group`,"aria-label":`Language`,children:[`en`,`zh`,`ru`].map(r=>(0,H.jsx)(`button`,{className:e===r?`active`:``,type:`button`,"aria-pressed":e===r,onClick:()=>t(r),children:n(`language.${r}`)},r))})}function Te(e,t,n){let r=be[e][t]??be.en[t]??t;return n?r.replace(/\{(\w+)\}/g,(e,t)=>String(n[t]??``)):r}function Ee(){try{let e=De(new URLSearchParams(window.location.search).get(`lang`));if(e)return e}catch{}try{let e=De(localStorage.getItem(U));if(e)return e}catch{}let e=navigator.languages?.length?navigator.languages:[navigator.language];for(let t of e){let e=De(t);if(e)return e}return`en`}function De(e){if(!e)return null;let t=e.trim().toLowerCase().replace(`_`,`-`);return t===`zh`||t.startsWith(`zh-`)?`zh`:t===`en`||t.startsWith(`en-`)?`en`:t===`ru`||t.startsWith(`ru-`)?`ru`:null}function Oe(){return{href:`${window.location.pathname}${window.location.search}`,path:window.location.pathname,search:new URLSearchParams(window.location.search)}}function ke(e,t){return e.startsWith(`/accounts`)?t(`route.accounts`):e.startsWith(`/channels`)?t(`route.channels`):e.startsWith(`/messages`)?t(`route.messages`):e.startsWith(`/gifts`)?t(`route.gifts`):t(`route.dashboard`)}function Ae(e,t){return e.startsWith(`/accounts`)?t(`route.accountsSubtitle`):e.startsWith(`/channels`)?t(`route.channelsSubtitle`):e.startsWith(`/messages`)?t(`route.messagesSubtitle`):e.startsWith(`/gifts`)?t(`route.giftsSubtitle`):t(`route.dashboardSubtitle`)}function je({href:e,navigate:t,className:n,children:r}){return(0,H.jsx)(`a`,{className:n,href:e,onClick:n=>{n.preventDefault(),t(e)},children:r})}function Me(){let{t:e}=Ce();return(0,H.jsxs)(`div`,{className:`boot-screen`,children:[(0,H.jsxs)(`div`,{className:`brand compact brand-elevated`,children:[(0,H.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,H.jsxs)(`span`,{children:[(0,H.jsx)(`strong`,{children:`telesrv`}),(0,H.jsx)(`small`,{children:e(`app.adminConsole`)})]})]}),(0,H.jsx)(`div`,{className:`loader-bar`})]})}function Ne({actor:e,route:t,navigate:n,onLogout:r,children:i}){let{t:a}=Ce(),o=t.path.startsWith(`/messages`),[s,c]=(0,g.useState)(o);(0,g.useEffect)(()=>{o&&c(!0)},[o]);async function l(){await x.logout().catch(()=>void 0),r()}return(0,H.jsxs)(`div`,{className:`shell`,children:[(0,H.jsxs)(`aside`,{className:`sidebar`,children:[(0,H.jsxs)(je,{className:`brand`,href:`/`,navigate:n,children:[(0,H.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,H.jsxs)(`span`,{children:[(0,H.jsx)(`strong`,{children:`telesrv`}),(0,H.jsx)(`small`,{children:a(`app.adminConsole`)})]})]}),(0,H.jsx)(`div`,{className:`sidebar-label`,children:a(`layout.navigation`)}),(0,H.jsxs)(`nav`,{className:`nav-list`,"aria-label":a(`layout.primaryNav`),children:[(0,H.jsx)(Pe,{icon:(0,H.jsx)(oe,{size:16}),href:`/`,route:t,navigate:n,children:a(`layout.dashboard`)}),(0,H.jsx)(Pe,{icon:(0,H.jsx)(_e,{size:16}),href:`/accounts`,route:t,navigate:n,children:a(`layout.accounts`)}),(0,H.jsx)(Pe,{icon:(0,H.jsx)(fe,{size:16}),href:`/channels`,route:t,navigate:n,children:a(`layout.channels`)}),(0,H.jsx)(Pe,{icon:(0,H.jsx)(re,{size:16}),href:`/gifts`,route:t,navigate:n,children:a(`layout.gifts`)}),(0,H.jsxs)(`div`,{className:`nav-section ${o?`active`:``} ${s?`open`:``}`,children:[(0,H.jsxs)(`button`,{className:`nav-section-toggle`,type:`button`,"aria-expanded":s,onClick:()=>c(e=>!e),children:[(0,H.jsx)(ce,{size:16}),(0,H.jsx)(`span`,{children:a(`layout.messages`)}),(0,H.jsx)(F,{className:`nav-section-chevron`,size:15})]}),s&&(0,H.jsxs)(`div`,{className:`nav-children`,children:[(0,H.jsx)(Pe,{href:`/messages/private`,route:t,navigate:n,activeWhen:e=>e===`/messages`||e===`/messages/detail`||e.startsWith(`/messages/private`),children:a(`layout.privateMessages`)}),(0,H.jsx)(Pe,{href:`/messages/groups`,route:t,navigate:n,activeWhen:e=>e.startsWith(`/messages/groups`),children:a(`layout.groupMessages`)})]})]})]}),(0,H.jsxs)(`div`,{className:`sidebar-status`,children:[(0,H.jsx)(`div`,{className:`sidebar-label`,children:a(`layout.runtime`)}),(0,H.jsxs)(`div`,{className:`runtime-row`,children:[(0,H.jsx)(V,{size:14}),(0,H.jsx)(`span`,{children:a(`layout.adminBackend`)}),(0,H.jsx)(`strong`,{children:a(`layout.ready`)})]}),(0,H.jsxs)(`div`,{className:`runtime-row`,children:[(0,H.jsx)(ee,{size:14}),(0,H.jsx)(`span`,{children:a(`layout.pgRead`)}),(0,H.jsx)(`strong`,{children:a(`layout.readOnly`)})]}),(0,H.jsxs)(`div`,{className:`runtime-row`,children:[(0,H.jsx)(pe,{size:14}),(0,H.jsx)(`span`,{children:a(`layout.writeOps`)}),(0,H.jsx)(`strong`,{children:a(`layout.dryRun`)})]})]})]}),(0,H.jsxs)(`div`,{className:`workspace`,children:[(0,H.jsxs)(`header`,{className:`topbar`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:Ae(t.path,a)}),(0,H.jsx)(`h1`,{children:ke(t.path,a)})]}),(0,H.jsxs)(`div`,{className:`topbar-actions`,children:[(0,H.jsx)(we,{}),(0,H.jsx)(`span`,{className:`actor-pill`,children:a(`layout.actor`,{actor:e})}),(0,H.jsxs)(`button`,{className:`btn ghost icon-text`,type:`button`,onClick:l,title:a(`layout.logout`),children:[(0,H.jsx)(se,{size:16}),` `,a(`layout.logout`)]})]})]}),(0,H.jsx)(`main`,{className:`content`,children:i})]})]})}function Pe({href:e,route:t,navigate:n,icon:r,children:i,activeWhen:a}){return(0,H.jsxs)(je,{className:`nav-item ${(a?a(t.path):e===`/`?t.path===`/`:t.path.startsWith(e))?`active`:``}`,href:e,navigate:n,children:[r??(0,H.jsx)(`span`,{"aria-hidden":`true`,className:`nav-dot`}),(0,H.jsx)(`span`,{children:i})]})}function Fe(e){let t=e.trim();return!t||t.startsWith(`+`)?t:/^\d+$/.test(t)?`+${t}`:t}function Ie(e){let t=e.trim();return t?t.startsWith(`@`)?t:`@${t}`:``}function Le(e){return`${e.FirstName||``} ${e.LastName||``}`.trim()||`-`}function Re(e,t){let n=t??(e=>({"channel.kind.broadcast":`Channel`,"channel.kind.forum":`Supergroup / Forum`,"channel.kind.megagroup":`Supergroup`,"channel.kind.generic":`Channel / Group`})[e]??e);return e.Broadcast&&!e.Megagroup?n(`channel.kind.broadcast`):e.Megagroup&&e.Forum?n(`channel.kind.forum`):e.Megagroup?n(`channel.kind.megagroup`):n(`channel.kind.generic`)}function ze(e){if(!e||e.startsWith(`0001-`))return``;let t=new Date(e);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function Be(e){if(!e||e<=0)return``;let t=new Date(e*1e3);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function W(e){if(!e.trim())return 0;let t=Number.parseInt(e,10);return Number.isFinite(t)?t:0}function Ve(e,t=`msg ids invalid`){let n=e.split(/[\s,]+/).map(e=>e.trim()).filter(Boolean).map(e=>Number.parseInt(e,10));if(n.length===0||n.some(e=>!Number.isFinite(e)||e<=0))throw Error(t);return n}function He({title:e,eyebrow:t,children:n,actions:r}){return(0,H.jsxs)(`div`,{className:`page-frame`,children:[(0,H.jsxs)(`div`,{className:`page-title-row`,children:[(0,H.jsxs)(`div`,{children:[t&&(0,H.jsx)(`div`,{className:`eyebrow`,children:t}),(0,H.jsx)(`h2`,{children:e})]}),r&&(0,H.jsx)(`div`,{className:`page-actions`,children:r})]}),n]})}function Ue({children:e}){return(0,H.jsx)(`div`,{className:`query-panel`,children:e})}function We({main:e,side:t}){return(0,H.jsxs)(`div`,{className:`split-layout`,children:[(0,H.jsx)(`div`,{className:`split-main`,children:e}),(0,H.jsx)(`aside`,{className:`split-side`,children:t})]})}function Ge({title:e,text:t,action:n}){return(0,H.jsxs)(`div`,{className:`section-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`h2`,{children:e}),t&&(0,H.jsx)(`p`,{children:t})]}),n&&(0,H.jsx)(`div`,{className:`section-action`,children:n})]})}function Ke({children:e}){return(0,H.jsxs)(`div`,{className:`alert`,children:[(0,H.jsx)(O,{size:16}),` `,(0,H.jsx)(`span`,{children:e})]})}function G({children:e,tone:t=`neutral`}){return(0,H.jsx)(`span`,{className:`badge ${t}`,children:e})}function K({label:e,value:t,tone:n}){return(0,H.jsxs)(`div`,{className:`status-item ${n}`,children:[(0,H.jsx)(`span`,{children:e}),(0,H.jsx)(`strong`,{children:t})]})}function q({label:e,value:t,tone:n=`neutral`,mono:r=!1}){return(0,H.jsxs)(`div`,{className:`metric ${n}`,children:[(0,H.jsx)(`span`,{children:e}),(0,H.jsx)(`strong`,{className:r?`mono`:``,children:t})]})}function J({label:e,value:t,mono:n=!1}){return(0,H.jsxs)(`div`,{className:`summary-item`,children:[(0,H.jsx)(`span`,{children:e}),(0,H.jsx)(`strong`,{className:n?`mono`:``,children:t})]})}function qe({rows:e}){let{t}=Ce();return(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`audit.id`)}),(0,H.jsx)(`th`,{children:t(`audit.commandID`)}),(0,H.jsx)(`th`,{children:t(`audit.action`)}),(0,H.jsx)(`th`,{children:t(`audit.actor`)}),(0,H.jsx)(`th`,{children:t(`audit.status`)}),(0,H.jsx)(`th`,{children:t(`audit.dryRun`)}),(0,H.jsx)(`th`,{children:t(`audit.reason`)}),(0,H.jsx)(`th`,{children:t(`audit.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[e.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.ID}),(0,H.jsx)(`td`,{className:`mono`,children:e.CommandID}),(0,H.jsx)(`td`,{children:e.Action}),(0,H.jsx)(`td`,{children:e.Actor}),(0,H.jsx)(`td`,{children:e.Status}),(0,H.jsx)(`td`,{children:e.DryRun?t(`common.yes`):t(`common.no`)}),(0,H.jsx)(`td`,{className:`truncate`,children:e.Reason}),(0,H.jsx)(`td`,{children:ze(e.CreatedAt)})]},e.ID)),e.length===0&&(0,H.jsx)(Je,{colSpan:8})]})]})})}function Je({colSpan:e}){let{t}=Ce();return(0,H.jsx)(`tr`,{children:(0,H.jsx)(`td`,{colSpan:e,className:`empty-cell`,children:t(`common.noResults`)})})}function Ye({label:e}){return(0,H.jsx)(`section`,{className:`surface`,children:(0,H.jsx)(`div`,{className:`loading-line`,children:e})})}function Xe({value:e}){return(0,H.jsx)(`pre`,{className:`json-block`,children:e||`{}`})}function Ze({onLogin:e}){let{t}=Ce(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(!1);async function c(t){t.preventDefault(),s(!0),a(``);try{e((await x.login(n)).actor)}catch(e){a(b(e))}finally{s(!1)}}return(0,H.jsx)(`main`,{className:`login-page`,children:(0,H.jsxs)(`section`,{className:`login-panel`,children:[(0,H.jsxs)(`div`,{className:`login-head`,children:[(0,H.jsxs)(`div`,{className:`brand brand-elevated`,children:[(0,H.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,H.jsxs)(`span`,{children:[(0,H.jsx)(`strong`,{children:`telesrv`}),(0,H.jsx)(`small`,{children:t(`app.adminConsole`)})]})]}),(0,H.jsxs)(`div`,{className:`login-head-actions`,children:[(0,H.jsx)(we,{}),(0,H.jsx)(`span`,{className:`login-chip`,children:t(`app.localAccess`)})]})]}),(0,H.jsxs)(`div`,{className:`login-copy`,children:[(0,H.jsx)(`h1`,{children:t(`login.heading`)}),(0,H.jsx)(`p`,{children:t(`login.body`)})]}),i&&(0,H.jsx)(Ke,{children:i}),(0,H.jsxs)(`form`,{className:`form-stack`,onSubmit:c,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:t(`login.secret`)}),(0,H.jsx)(`input`,{autoFocus:!0,type:`password`,value:n,autoComplete:`current-password`,onChange:e=>r(e.target.value)})]}),(0,H.jsx)(`button`,{className:`btn primary full`,type:`submit`,disabled:o,children:t(o?`login.submitting`:`login.submit`)})]})]})})}var Qe=m();function $e({label:e,path:t,payload:n,icon:r,compact:i=!1,tone:a=`danger`,onDone:o}){let{t:s}=Ce(),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``),[f,p]=(0,g.useState)(null),[m,h]=(0,g.useState)(``),[_,v]=(0,g.useState)(!1);function y(){d(``),p(null),h(``)}async function S(e){if(!u.trim()){h(s(`action.reasonRequired`));return}v(!0),h(``);try{let r={...n(),reason:u,confirm:e};p(await x.action(t,r)),e&&o?.()}catch(e){h(b(e))}finally{v(!1)}}let C=f?.dry_run&&!f.error,w=`btn ${a===`danger`?`danger`:a===`warn`?`warn`:``} ${i?`compact-btn`:``}`,T=(0,g.useMemo)(()=>{try{return n()}catch(e){return{payload_error:b(e)}}},[c,n]);return(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:w,type:`button`,onClick:()=>{y(),l(!0)},children:[r,e]}),c&&(0,Qe.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":e,children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:s(`action.flow`)}),(0,H.jsx)(`h2`,{children:e})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>l(!1),"aria-label":s(`action.close`),children:(0,H.jsx)(ve,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body`,children:[(0,H.jsxs)(`div`,{className:`command-steps`,children:[(0,H.jsxs)(`div`,{className:`command-step ${u.trim()?`done`:`active`}`,children:[(0,H.jsx)(`span`,{children:`1`}),(0,H.jsx)(`strong`,{children:s(`action.stepReason`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${f?.dry_run?`done`:u.trim()?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`2`}),(0,H.jsx)(`strong`,{children:s(`action.stepDryRun`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${f&&!f.dry_run&&!f.error?`done`:C?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`3`}),(0,H.jsx)(`strong`,{children:s(`action.stepConfirm`)})]})]}),(0,H.jsxs)(`label`,{className:`form-field`,children:[(0,H.jsx)(`span`,{children:s(`action.reason`)}),(0,H.jsx)(`textarea`,{value:u,onChange:e=>d(e.target.value),rows:3,placeholder:s(`action.reasonPlaceholder`)})]}),(0,H.jsxs)(`div`,{className:`command-preview`,children:[(0,H.jsxs)(`div`,{className:`preview-head`,children:[(0,H.jsx)(te,{size:14}),` `,s(`action.requestPreview`)]}),(0,H.jsx)(Xe,{value:JSON.stringify(T,null,2)})]}),m&&(0,H.jsx)(Ke,{children:m}),f&&(0,H.jsxs)(`div`,{className:`result-box`,children:[(0,H.jsxs)(`div`,{className:`result-title`,children:[f.error?(0,H.jsx)(O,{size:16}):(0,H.jsx)(k,{size:16}),(0,H.jsx)(`strong`,{children:f.message||f.error||s(`action.result`)})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:s(`action.commandID`)}),(0,H.jsx)(`strong`,{children:f.command_id})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:s(`action.status`)}),(0,H.jsx)(`strong`,{children:f.status})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:s(`action.dryRun`)}),(0,H.jsx)(`strong`,{children:f.dry_run?s(`common.yes`):s(`common.no`)})]}),(0,H.jsx)(`div`,{className:`result-message`,children:f.message||f.error}),f.details&&(0,H.jsx)(Xe,{value:JSON.stringify(f.details,null,2)})]})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>l(!1),children:s(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>S(!1),disabled:_,children:[_?(0,H.jsx)(A,{size:15,className:`spin`}):(0,H.jsx)(ue,{size:15}),s(f?`action.runAgain`:`action.runDry`)]}),(0,H.jsxs)(`button`,{className:`btn danger icon-text`,type:`button`,onClick:()=>S(!0),disabled:_||!C,children:[(0,H.jsx)(k,{size:15}),s(`action.confirm`)]})]})]})}),document.body)]})}function et({rows:e,userID:t,onDone:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(()=>new Set);(0,g.useEffect)(()=>{a(new Set)},[t]);let o=(0,g.useMemo)(()=>e.filter(e=>!i.has(e.Hash)),[e,i]);function s(e){a(t=>e(t)),n()}return(0,H.jsxs)(`div`,{className:`authorization-block`,children:[(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table authorization-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:r(`auth.device`)}),(0,H.jsx)(`th`,{children:r(`auth.platform`)}),(0,H.jsx)(`th`,{children:r(`auth.ip`)}),(0,H.jsx)(`th`,{children:r(`auth.lastActive`)}),(0,H.jsx)(`th`,{className:`device-actions-head`,children:r(`common.actions`)})]})}),(0,H.jsxs)(`tbody`,{children:[o.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsxs)(`td`,{className:`device-text`,children:[n.DeviceModel,` `,n.SystemVersion]}),(0,H.jsxs)(`td`,{className:`device-text`,children:[n.Platform,` `,n.AppVersion]}),(0,H.jsx)(`td`,{children:n.IP}),(0,H.jsx)(`td`,{children:ze(n.ActiveAt)}),(0,H.jsx)(`td`,{className:`device-actions-cell`,children:(0,H.jsxs)(`div`,{className:`device-actions`,children:[(0,H.jsx)($e,{label:r(`auth.revokeCurrent`),icon:(0,H.jsx)(se,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,hash:n.Hash}),onDone:()=>s(e=>new Set([...e,n.Hash]))}),(0,H.jsx)($e,{label:r(`auth.keepCurrent`),icon:(0,H.jsx)(fe,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,keep_hash:n.Hash}),onDone:()=>s(()=>new Set(e.filter(e=>e.Hash!==n.Hash).map(e=>e.Hash)))})]})})]},n.Hash)),o.length===0&&(0,H.jsx)(Je,{colSpan:5})]})]})}),(0,H.jsx)(`div`,{className:`danger-zone`,children:(0,H.jsx)($e,{label:r(`auth.revokeAll`),icon:(0,H.jsx)(N,{size:15}),path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,revoke_all:!0}),onDone:()=>s(()=>new Set(e.map(e=>e.Hash)))})})]})}function tt({id:e,navigate:t}){let{t:n}=Ce(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(`1`),[d,f]=(0,g.useState)(`1000`),[p,m]=(0,g.useState)(()=>nt(new Date(Date.now()+7*864e5))),[h,_]=(0,g.useState)(``);async function v(){c(!0),o(``);try{let t=await x.account(e);i(t),t.Restriction.Frozen&&(t.Restriction.Until&&m(nt(new Date(t.Restriction.Until))),_(t.Restriction.AppealURL||``))}catch(e){o(b(e))}finally{c(!1)}}if((0,g.useEffect)(()=>{v()},[e]),a)return(0,H.jsx)(Ke,{children:a});if(!r)return(0,H.jsx)(Ye,{label:n(s?`account.loadingDetail`:`account.waitingData`)});let y=r.Account;return(0,H.jsx)(He,{title:n(`account.detailTitle`,{id:y.ID}),eyebrow:n(`account.profile`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/accounts`),children:[(0,H.jsx)(M,{size:15}),` `,n(`common.backToList`)]}),children:(0,H.jsx)(We,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:Le(y)}),(0,H.jsxs)(`div`,{className:`entity-subtitle`,children:[Ie(y.Username)||n(`account.noUsername`),` · `,Fe(y.Phone)||n(`account.noPhone`)]})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[y.PremiumUntil>0?(0,H.jsx)(G,{tone:`good`,children:n(`account.premium`)}):(0,H.jsx)(G,{children:n(`account.notPremium`)}),r.Verified?(0,H.jsx)(G,{tone:`good`,children:n(`common.verified`)}):(0,H.jsx)(G,{children:n(`account.notVerified`)}),y.Frozen?(0,H.jsx)(G,{tone:`danger`,children:n(`account.accountFrozen`)}):(0,H.jsx)(G,{children:n(`account.accountActive`)})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(J,{label:n(`account.userID`),value:String(y.ID),mono:!0}),(0,H.jsx)(J,{label:n(`account.lastActive`),value:Be(r.LastSeenAt)||`-`}),(0,H.jsx)(J,{label:n(`account.premiumUntil`),value:y.PremiumUntil>0?Be(y.PremiumUntil):n(`common.none`)}),(0,H.jsx)(J,{label:n(`account.starsBalance`),value:`${r.StarsBalance} / ${r.StarsGranted?n(`account.startingGrantApplied`):n(`account.startingGrantPending`)}`}),(0,H.jsx)(J,{label:n(`common.updatedAt`),value:ze(y.UpdatedAt)||`-`}),(0,H.jsx)(J,{label:n(`account.activeSessions`),value:String(r.Authorizations.length)}),(0,H.jsx)(J,{label:n(`account.accountFlags`),value:`support=${r.Support} bot=${r.Bot}`}),(0,H.jsx)(J,{label:n(`account.restriction`),value:r.HasRestriction?r.Restriction.Reason||n(`account.restricted`):n(`common.none`)}),(0,H.jsx)(J,{label:n(`account.freezeSince`),value:r.Restriction.Since?ze(r.Restriction.Since):n(`common.none`)}),(0,H.jsx)(J,{label:n(`account.freezeUntil`),value:r.Restriction.Until?ze(r.Restriction.Until):n(`common.none`)}),(0,H.jsx)(J,{label:n(`account.freezeAppealURL`),value:r.Restriction.AppealURL||n(`common.none`)}),(0,H.jsx)(J,{label:n(`account.createdAt`),value:ze(y.CreatedAt)||`-`})]}),r.About&&(0,H.jsx)(`p`,{className:`about-text`,children:r.About}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:n(`account.authorizationsTitle`),text:n(`account.authorizationsCount`,{count:r.Authorizations.length})}),(0,H.jsx)(et,{rows:r.Authorizations,userID:y.ID,onDone:v})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:n(`account.recentAdminOps`),text:n(`account.recent30Audit`)}),(0,H.jsx)(qe,{rows:r.AuditLogs})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`account.actionDock`)}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.freezeUntil`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.freezeUntilAria`),value:p,onChange:e=>m(e.target.value),type:`datetime-local`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.freezeAppealURL`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.freezeAppealURLAria`),value:h,onChange:e=>_(e.target.value),type:`url`,placeholder:`https://...`})]}),(0,H.jsx)($e,{label:y.Frozen?n(`account.updateFreeze`):n(`account.freezeAccount`),icon:(0,H.jsx)(O,{size:15}),path:`/api/actions/set-frozen`,payload:()=>({user_id:y.ID,frozen:!0,freeze_until:new Date(p).toISOString(),freeze_appeal_url:h.trim()}),onDone:v}),y.Frozen&&(0,H.jsx)($e,{label:n(`account.unfreezeAccount`),icon:(0,H.jsx)(O,{size:15}),path:`/api/actions/set-frozen`,payload:()=>({user_id:y.ID,frozen:!1}),onDone:v}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.premiumMonths`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.premiumMonthsAria`),value:l,onChange:e=>u(e.target.value),type:`number`,min:`1`,max:`120`})]}),(0,H.jsxs)(`div`,{className:`action-stack`,children:[(0,H.jsx)($e,{label:n(`account.setPremium`),icon:(0,H.jsx)(j,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:y.ID,months:W(l)}),onDone:v}),(0,H.jsx)($e,{label:n(`account.clearPremium`),icon:(0,H.jsx)(j,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:y.ID,months:0}),onDone:v}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.starsAmount`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.starsAmountAria`),value:d,onChange:e=>f(e.target.value),type:`number`,min:`1`,max:`1000000000`})]}),(0,H.jsx)($e,{label:n(`account.grantStars`),icon:(0,H.jsx)(me,{size:15}),tone:`warn`,path:`/api/actions/grant-stars`,payload:()=>({user_id:y.ID,amount:W(d)}),onDone:v}),(0,H.jsx)($e,{label:r.Verified?n(`account.clearVerified`):n(`account.setVerified`),icon:(0,H.jsx)(D,{size:15}),tone:`warn`,path:`/api/actions/set-verified`,payload:()=>({user_id:y.ID,verified:!r.Verified}),onDone:v})]})]})})})}function nt(e){return new Date(e.getTime()-e.getTimezoneOffset()*6e4).toISOString().slice(0,16)}function rt(e){return e.reduce((e,t)=>(e.devices+=t.DeviceCount,t.PremiumUntil>0&&(e.premium+=1),t.Frozen&&(e.frozen+=1),e),{devices:0,premium:0,frozen:0})}function it(e){return e.reduce((e,t)=>(t.Megagroup&&(e.megagroups+=1),t.Broadcast&&(e.broadcasts+=1),t.Verified&&(e.verified+=1),e),{megagroups:0,broadcasts:0,verified:0})}function at({navigate:e}){let{t}=Ce(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(`50`),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)({beforeID:0,beforeActiveUS:0}),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``);async function m(e=!1){d(!0),p(``);let t=new URLSearchParams({limit:i});n.trim()?t.set(`q`,n.trim()):e&&(t.set(`before_id`,String(c.beforeID)),t.set(`before_active_us`,String(c.beforeActiveUS)));try{let e=await x.accounts(t);s(e),l({beforeID:e.next_before_id,beforeActiveUS:e.next_before_active_us})}catch(e){p(b(e))}finally{d(!1)}}(0,g.useEffect)(()=>{m(!1)},[]);let h=rt(o?.rows??[]);return(0,H.jsxs)(He,{title:t(`account.pageTitle`),eyebrow:o?.listing===!1?t(`account.queryResults`):t(`account.recentActive`),actions:(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>m(!1),disabled:u,children:[(0,H.jsx)(de,{size:15}),` `,t(`common.refresh`)]}),children:[f&&(0,H.jsx)(Ke,{children:f}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(q,{label:t(`account.currentPage`),value:String(o?.rows.length??0)}),(0,H.jsx)(q,{label:t(`account.onlineDevices`),value:String(h.devices)}),(0,H.jsx)(q,{label:t(`account.premium`),value:String(h.premium),tone:`good`}),(0,H.jsx)(q,{label:t(`account.frozen`),value:String(h.frozen),tone:h.frozen>0?`danger`:`neutral`})]}),(0,H.jsx)(Ue,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`account.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:i,onChange:e=>a(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:u,children:[u?(0,H.jsx)(A,{size:15,className:`spin`}):(0,H.jsx)(B,{size:15}),` `,t(`common.search`)]}),o?.listing&&o.has_more&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),disabled:u,children:[(0,H.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`account.userID`)}),(0,H.jsx)(`th`,{children:t(`account.phone`)}),(0,H.jsx)(`th`,{children:t(`common.username`)}),(0,H.jsx)(`th`,{children:t(`common.name`)}),(0,H.jsx)(`th`,{children:t(`common.device`)}),(0,H.jsx)(`th`,{children:t(`account.lastActive`)}),(0,H.jsx)(`th`,{children:t(`account.premium`)}),(0,H.jsx)(`th`,{children:t(`common.verified`)}),(0,H.jsx)(`th`,{children:t(`account.frozen`)}),(0,H.jsx)(`th`,{children:t(`common.updatedAt`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[o?.rows.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.ID}),(0,H.jsx)(`td`,{children:Fe(n.Phone)}),(0,H.jsx)(`td`,{children:Ie(n.Username)}),(0,H.jsx)(`td`,{children:Le(n)}),(0,H.jsx)(`td`,{children:n.DeviceCount}),(0,H.jsx)(`td`,{children:ze(n.LastActiveAt)}),(0,H.jsx)(`td`,{children:n.PremiumUntil>0?(0,H.jsxs)(G,{tone:`good`,children:[t(`account.premium`),` `,Be(n.PremiumUntil)]}):(0,H.jsx)(G,{children:t(`common.none`)})}),(0,H.jsx)(`td`,{children:n.Verified?(0,H.jsx)(G,{tone:`good`,children:t(`common.verified`)}):(0,H.jsx)(G,{children:t(`account.notVerified`)})}),(0,H.jsx)(`td`,{children:n.Frozen?(0,H.jsx)(G,{tone:`danger`,children:t(`account.frozen`)}):(0,H.jsx)(G,{children:t(`common.normal`)})}),(0,H.jsx)(`td`,{children:ze(n.UpdatedAt)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/accounts/${n.ID}`),children:[t(`common.detail`),` `,(0,H.jsx)(I,{size:14})]})})]},n.ID)),(!o||o.rows.length===0)&&(0,H.jsx)(Je,{colSpan:11})]})]})})]})}function ot({id:e,navigate:t}){let{t:n}=Ce(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``);async function s(){o(``);try{i(await x.channel(e))}catch(e){o(b(e))}}if((0,g.useEffect)(()=>{s()},[e]),a)return(0,H.jsx)(Ke,{children:a});if(!r)return(0,H.jsx)(Ye,{label:n(`channel.loadingDetail`)});let c=r.Channel;return(0,H.jsx)(He,{title:`${Re(c,n)} #${c.ID}`,eyebrow:n(`channel.detailProfile`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/channels`),children:[(0,H.jsx)(M,{size:15}),` `,n(`common.backToList`)]}),children:(0,H.jsx)(We,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:c.Title||`-`}),(0,H.jsxs)(`div`,{className:`entity-subtitle`,children:[Ie(c.Username)||n(`account.noUsername`),` · `,n(`channel.creator`,{id:c.CreatorUserID})]})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[(0,H.jsx)(G,{children:Re(c,n)}),c.Verified?(0,H.jsx)(G,{tone:`good`,children:n(`common.verified`)}):(0,H.jsx)(G,{children:n(`account.notVerified`)}),c.Deleted?(0,H.jsx)(G,{tone:`danger`,children:n(`common.deleted`)}):(0,H.jsx)(G,{children:n(`common.valid`)})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(J,{label:n(`channel.channelID`),value:String(c.ID),mono:!0}),(0,H.jsx)(J,{label:`access_hash`,value:String(c.AccessHash),mono:!0}),(0,H.jsx)(J,{label:n(`common.members`),value:`${c.ParticipantsCount} / ${n(`common.admins`)} ${c.AdminsCount}`}),(0,H.jsx)(J,{label:n(`channel.governance`),value:n(`channel.governanceValue`,{banned:c.BannedCount,kicked:c.KickedCount})}),(0,H.jsx)(J,{label:n(`channel.flags`),value:`broadcast=${c.Broadcast} megagroup=${c.Megagroup} forum=${c.Forum}`}),(0,H.jsx)(J,{label:`top / pinned / PTS`,value:`${c.TopMessageID} / ${c.PinnedMessageID} / ${c.PTS}`}),(0,H.jsx)(J,{label:n(`account.createdAt`),value:Be(c.Date)||`-`}),(0,H.jsx)(J,{label:n(`common.updatedAt`),value:ze(c.UpdatedAt)||`-`})]}),c.About&&(0,H.jsx)(`p`,{className:`about-text`,children:c.About}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:n(`account.recentAdminOps`),text:n(`account.recent30Audit`)}),(0,H.jsx)(qe,{rows:r.AuditLogs})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:n(`channel.rawRow`),text:n(`channel.rawRowText`)}),(0,H.jsx)(Xe,{value:r.ChannelJSON})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`channel.actionDock`)}),(0,H.jsx)($e,{label:c.Verified?n(`channel.clearVerified`):n(`channel.setVerified`),icon:(0,H.jsx)(D,{size:15}),tone:`warn`,path:`/api/actions/set-channel-verified`,payload:()=>({channel_id:c.ID,verified:!c.Verified}),onDone:s})]})})})}function st({navigate:e}){let{t}=Ce(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(`50`),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)({beforeID:0,beforeUpdatedUS:0}),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``);async function m(e=!1){d(!0),p(``);let t=new URLSearchParams({limit:i});n.trim()?t.set(`q`,n.trim()):e&&(t.set(`before_id`,String(c.beforeID)),t.set(`before_updated_us`,String(c.beforeUpdatedUS)));try{let e=await x.channels(t);s(e),l({beforeID:e.next_before_id,beforeUpdatedUS:e.next_before_updated_us})}catch(e){p(b(e))}finally{d(!1)}}(0,g.useEffect)(()=>{m(!1)},[]);let h=it(o?.rows??[]);return(0,H.jsxs)(He,{title:t(`channel.pageTitle`),eyebrow:o?.listing===!1?t(`account.queryResults`):t(`channel.recentUpdated`),actions:(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>m(!1),disabled:u,children:[(0,H.jsx)(de,{size:15}),` `,t(`common.refresh`)]}),children:[f&&(0,H.jsx)(Ke,{children:f}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(q,{label:t(`channel.currentPage`),value:String(o?.rows.length??0)}),(0,H.jsx)(q,{label:t(`channel.megagroups`),value:String(h.megagroups)}),(0,H.jsx)(q,{label:t(`channel.broadcasts`),value:String(h.broadcasts)}),(0,H.jsx)(q,{label:t(`channel.verifiedCount`),value:String(h.verified),tone:`good`})]}),(0,H.jsx)(Ue,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`channel.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:i,onChange:e=>a(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:u,children:[u?(0,H.jsx)(A,{size:15,className:`spin`}):(0,H.jsx)(B,{size:15}),` `,t(`common.search`)]}),o?.listing&&o.has_more&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),disabled:u,children:[(0,H.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`channel.channelID`)}),(0,H.jsx)(`th`,{children:t(`channel.kind`)}),(0,H.jsx)(`th`,{children:t(`common.username`)}),(0,H.jsx)(`th`,{children:t(`channel.title`)}),(0,H.jsx)(`th`,{children:t(`common.members`)}),(0,H.jsx)(`th`,{children:t(`common.admins`)}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:t(`common.verified`)}),(0,H.jsx)(`th`,{children:t(`common.updatedAt`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[o?.rows.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.ID}),(0,H.jsx)(`td`,{children:Re(n,t)}),(0,H.jsx)(`td`,{children:Ie(n.Username)}),(0,H.jsx)(`td`,{children:n.Title}),(0,H.jsx)(`td`,{children:n.ParticipantsCount}),(0,H.jsx)(`td`,{children:n.AdminsCount}),(0,H.jsx)(`td`,{children:n.PTS}),(0,H.jsx)(`td`,{children:n.Verified?(0,H.jsx)(G,{tone:`good`,children:t(`common.verified`)}):(0,H.jsx)(G,{children:t(`account.notVerified`)})}),(0,H.jsx)(`td`,{children:ze(n.UpdatedAt)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/channels/${n.ID}`),children:[t(`common.detail`),` `,(0,H.jsx)(I,{size:14})]})})]},n.ID)),(!o||o.rows.length===0)&&(0,H.jsx)(Je,{colSpan:10})]})]})})]})}function ct({navigate:e}){let{t}=Ce();return(0,H.jsxs)(`div`,{className:`dashboard-layout`,children:[(0,H.jsxs)(`section`,{className:`overview-band`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:t(`dashboard.eyebrow`)}),(0,H.jsx)(`h2`,{children:t(`dashboard.title`)})]}),(0,H.jsxs)(`div`,{className:`overview-metrics`,children:[(0,H.jsx)(K,{label:t(`dashboard.readPath`),value:t(`dashboard.readPathValue`),tone:`neutral`}),(0,H.jsx)(K,{label:t(`dashboard.writePath`),value:`Admin API`,tone:`good`}),(0,H.jsx)(K,{label:t(`dashboard.executionPolicy`),value:t(`dashboard.dryRunFirst`),tone:`warn`})]})]}),(0,H.jsxs)(`div`,{className:`command-grid`,children:[(0,H.jsx)(lt,{icon:(0,H.jsx)(_e,{}),title:t(`route.accounts`),text:t(`dashboard.accountsText`),href:`/accounts`,navigate:e}),(0,H.jsx)(lt,{icon:(0,H.jsx)(fe,{}),title:t(`route.channels`),text:t(`dashboard.channelsText`),href:`/channels`,navigate:e}),(0,H.jsx)(lt,{icon:(0,H.jsx)(ce,{}),title:t(`route.messages`),text:t(`dashboard.messagesText`),href:`/messages`,navigate:e})]}),(0,H.jsxs)(`section`,{className:`work-strip`,children:[(0,H.jsxs)(`div`,{className:`strip-item`,children:[(0,H.jsx)(k,{size:16}),(0,H.jsx)(`span`,{children:t(`dashboard.strip.dryRun`)})]}),(0,H.jsxs)(`div`,{className:`strip-item`,children:[(0,H.jsx)(ae,{size:16}),(0,H.jsx)(`span`,{children:t(`dashboard.strip.token`)})]}),(0,H.jsxs)(`div`,{className:`strip-item`,children:[(0,H.jsx)(L,{size:16}),(0,H.jsx)(`span`,{children:t(`dashboard.strip.pagination`)})]}),(0,H.jsxs)(`div`,{className:`strip-item`,children:[(0,H.jsx)(te,{size:16}),(0,H.jsx)(`span`,{children:t(`dashboard.strip.snapshot`)})]})]})]})}function lt({icon:e,title:t,text:n,href:r,navigate:i}){return(0,H.jsxs)(je,{className:`launcher`,href:r,navigate:i,children:[(0,H.jsx)(`span`,{className:`launcher-icon`,children:e}),(0,H.jsxs)(`span`,{className:`launcher-copy`,children:[(0,H.jsx)(`strong`,{children:t}),(0,H.jsx)(`span`,{children:n})]}),(0,H.jsx)(I,{size:16})]})}function ut({channelID:e,msgID:t,navigate:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``);async function c(){s(``);try{a(await x.groupMessage(e,t))}catch(e){s(b(e))}}if((0,g.useEffect)(()=>{c()},[e,t]),o)return(0,H.jsx)(Ke,{children:o});if(!i)return(0,H.jsx)(Ye,{label:r(`common.loading`)});let l=i.Message;return(0,H.jsx)(He,{title:r(`messages.groupDetailTitle`,{id:l.ID}),eyebrow:r(`messages.detailEyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/groups`),children:[(0,H.jsx)(M,{size:15}),` `,r(`messages.backGroup`)]}),children:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:r(`messages.channelGroupTitle`,{id:l.ChannelID})}),(0,H.jsx)(`div`,{className:`entity-subtitle`,children:r(`messages.senderSubtitle`,{sender:l.SenderUserID,date:Be(l.Date)})})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[l.Deleted?(0,H.jsx)(G,{tone:`danger`,children:r(`common.deleted`)}):(0,H.jsx)(G,{children:r(`common.survived`)}),l.Pinned&&(0,H.jsx)(G,{tone:`warn`,children:r(`messages.pinned`)}),l.Post&&(0,H.jsx)(G,{children:r(`messages.channelPost`)}),(0,H.jsxs)(G,{children:[`pts `,l.PTS]})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(J,{label:r(`common.messageId`),value:String(l.ID),mono:!0}),(0,H.jsx)(J,{label:r(`messages.channelGroup`),value:String(l.ChannelID),mono:!0}),(0,H.jsx)(J,{label:`From Peer`,value:`${l.FromPeerType}:${l.FromPeerID}`,mono:!0}),(0,H.jsx)(J,{label:r(`common.views`),value:String(l.ViewsCount)})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.channelMessageRow`),text:r(`messages.channelMessagesSnapshot`)}),(0,H.jsx)(Xe,{value:i.MessageJSON})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.channelRow`),text:r(`messages.channelSnapshot`)}),(0,H.jsx)(Xe,{value:i.ChannelJSON})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.channelUpdateEvents`),text:r(`messages.channelEventsSource`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:r(`common.count`)}),(0,H.jsx)(`th`,{children:r(`common.type`)}),(0,H.jsx)(`th`,{children:r(`common.messageId`)}),(0,H.jsx)(`th`,{children:r(`common.sender`)}),(0,H.jsx)(`th`,{children:r(`common.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[i.UpdateEvents.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.PTS}),(0,H.jsx)(`td`,{children:e.PTSCount}),(0,H.jsx)(`td`,{children:e.Type}),(0,H.jsx)(`td`,{children:e.MessageID}),(0,H.jsx)(`td`,{children:e.SenderUserID}),(0,H.jsx)(`td`,{children:Be(e.Date)})]},`${e.PTS}-${e.Type}-${e.MessageID}`)),i.UpdateEvents.length===0&&(0,H.jsx)(Je,{colSpan:6})]})]})})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.eventJson`)}),(0,H.jsxs)(`div`,{className:`raw-grid`,children:[i.UpdateEvents.map(e=>(0,H.jsx)(Xe,{value:e.JSON},`${e.PTS}-${e.Type}-json`)),i.UpdateEvents.length===0&&(0,H.jsx)(`div`,{className:`empty-panel`,children:r(`common.noResults`)})]})]})]})})}function dt({label:e,value:t,onChange:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)([]),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(){l(!0),d(``);let e=new URLSearchParams({limit:`20`});i.trim()&&e.set(`q`,i.trim());try{s((await x.accounts(e)).rows)}catch(e){d(b(e))}finally{l(!1)}}return(0,g.useEffect)(()=>{f()},[]),(0,H.jsxs)(`div`,{className:`entity-picker`,children:[(0,H.jsxs)(`div`,{className:`picker-head`,children:[(0,H.jsx)(`span`,{children:e}),t?(0,H.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,H.jsx)(ve,{size:13}),` `,r(`common.clear`)]}):null]}),t?(0,H.jsxs)(`div`,{className:`selected-entity`,children:[(0,H.jsx)(P,{size:15}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:Le(t)}),(0,H.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,H.jsx)(`span`,{children:Ie(t.Username)||Fe(t.Phone)||`-`})]}):null,(0,H.jsxs)(`div`,{className:`picker-search`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),f())},placeholder:r(`picker.userPlaceholder`)}),(0,H.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:f,disabled:c,children:c?(0,H.jsx)(A,{size:14,className:`spin`}):r(`common.search`)})]}),u&&(0,H.jsx)(`div`,{className:`picker-error`,children:u}),(0,H.jsxs)(`div`,{className:`picker-results`,children:[o.map(e=>(0,H.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,H.jsx)(`span`,{className:`mono`,children:e.ID}),(0,H.jsx)(`strong`,{children:Le(e)}),(0,H.jsx)(`span`,{children:Ie(e.Username)||Fe(e.Phone)||`-`}),e.Verified?(0,H.jsx)(G,{tone:`good`,children:r(`picker.verified`)}):(0,H.jsx)(G,{children:r(`picker.regular`)})]},e.ID)),o.length===0&&!c?(0,H.jsx)(`div`,{className:`picker-empty`,children:r(`common.noResults`)}):null]})]})}function ft({label:e,value:t,onChange:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)([]),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(){l(!0),d(``);let e=new URLSearchParams({limit:`20`});i.trim()&&e.set(`q`,i.trim());try{s((await x.channels(e)).rows)}catch(e){d(b(e))}finally{l(!1)}}return(0,g.useEffect)(()=>{f()},[]),(0,H.jsxs)(`div`,{className:`entity-picker`,children:[(0,H.jsxs)(`div`,{className:`picker-head`,children:[(0,H.jsx)(`span`,{children:e}),t?(0,H.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,H.jsx)(ve,{size:13}),` `,r(`common.clear`)]}):null]}),t?(0,H.jsxs)(`div`,{className:`selected-entity`,children:[(0,H.jsx)(P,{size:15}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:t.Title||`-`}),(0,H.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,H.jsx)(`span`,{children:Ie(t.Username)||Re(t,r)})]}):null,(0,H.jsxs)(`div`,{className:`picker-search`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),f())},placeholder:r(`picker.channelPlaceholder`)}),(0,H.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:f,disabled:c,children:c?(0,H.jsx)(A,{size:14,className:`spin`}):r(`common.search`)})]}),u&&(0,H.jsx)(`div`,{className:`picker-error`,children:u}),(0,H.jsxs)(`div`,{className:`picker-results`,children:[o.map(e=>(0,H.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,H.jsx)(`span`,{className:`mono`,children:e.ID}),(0,H.jsx)(`strong`,{children:e.Title||`-`}),(0,H.jsx)(`span`,{children:Ie(e.Username)||Re(e,r)}),e.Verified?(0,H.jsx)(G,{tone:`good`,children:r(`picker.verified`)}):(0,H.jsx)(G,{children:Re(e,r)})]},e.ID)),o.length===0&&!c?(0,H.jsx)(`div`,{className:`picker-empty`,children:r(`common.noResults`)}):null]})]})}function pt({navigate:e}){let{t}=Ce(),[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(`100`),[u,d]=(0,g.useState)(null),[f,p]=(0,g.useState)(``);async function m(e=!1){if(p(``),!n){p(t(`messages.selectChannel`));return}let r=new URLSearchParams({channel_id:String(n.ID),limit:c});if(e&&u?.rows.length){let e=u.rows[u.rows.length-1];r.set(`before_date`,String(e.Date)),r.set(`before_id`,String(e.ID)),a(String(e.Date)),s(String(e.ID))}else i&&r.set(`before_date`,i),o&&r.set(`before_id`,o);try{d(await x.groupMessages(r))}catch(e){p(b(e))}}function h(e){r(e),a(``),s(``),d(null)}let _=u?.rows??[];return(0,H.jsxs)(He,{title:t(`messages.groupTitle`),eyebrow:t(`messages.groupEyebrow`),children:[f&&(0,H.jsx)(Ke,{children:f}),(0,H.jsxs)(Ue,{children:[(0,H.jsx)(`div`,{className:`message-selector-grid single`,children:(0,H.jsx)(ft,{label:t(`messages.channelGroup`),value:n,onChange:h})}),(0,H.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),placeholder:t(`messages.beforeDatePlaceholder`)}),(0,H.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:t(`messages.beforeIDPlaceholder`)}),(0,H.jsx)(`input`,{className:`small-input`,value:c,onChange:e=>l(e.target.value),placeholder:t(`messages.limitPlaceholder`)}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,H.jsx)(B,{size:15}),` `,t(`messages.searchMessages`)]}),_.length?(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),children:[(0,H.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]}):null]})]}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(q,{label:t(`messages.currentPage`),value:String(_.length)}),(0,H.jsx)(q,{label:t(`messages.mediaCount`),value:String(_.filter(e=>e.Media&&e.Media!==`{}`).length)}),(0,H.jsx)(q,{label:t(`messages.channelPosts`),value:String(_.filter(e=>e.Post).length)}),(0,H.jsx)(q,{label:t(`messages.channelGroup`),value:n?`${n.Title||Re(n,t)} (${n.ID})`:`-`})]}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`common.messageId`)}),(0,H.jsx)(`th`,{children:t(`common.time`)}),(0,H.jsx)(`th`,{children:t(`common.sender`)}),(0,H.jsx)(`th`,{children:`From Peer`}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:t(`common.views`)}),(0,H.jsx)(`th`,{children:t(`common.status`)}),(0,H.jsx)(`th`,{children:t(`messages.body`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[_.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.ID}),(0,H.jsx)(`td`,{children:Be(n.Date)}),(0,H.jsx)(`td`,{className:`mono`,children:n.SenderUserID}),(0,H.jsxs)(`td`,{className:`mono`,children:[n.FromPeerType,`:`,n.FromPeerID]}),(0,H.jsx)(`td`,{children:n.PTS}),(0,H.jsx)(`td`,{children:n.ViewsCount}),(0,H.jsx)(`td`,{children:n.Deleted?(0,H.jsx)(G,{tone:`danger`,children:t(`common.deleted`)}):n.Pinned?(0,H.jsx)(G,{tone:`warn`,children:t(`messages.pinned`)}):(0,H.jsx)(G,{children:t(`common.survived`)})}),(0,H.jsx)(`td`,{className:`truncate`,children:n.Body}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/groups/detail?channel_id=${n.ChannelID}&msg_id=${n.ID}`),children:[t(`common.detail`),` `,(0,H.jsx)(I,{size:14})]})})]},`${n.ChannelID}-${n.ID}`)),_.length===0&&(0,H.jsx)(Je,{colSpan:9})]})]})})]})}function mt({ownerUserID:e,msgID:t,navigate:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``);async function c(){s(``);try{a(await x.message(e,t))}catch(e){s(b(e))}}if((0,g.useEffect)(()=>{c()},[e,t]),o)return(0,H.jsx)(Ke,{children:o});if(!i)return(0,H.jsx)(Ye,{label:r(`common.loading`)});let l=i.Message;return(0,H.jsx)(He,{title:r(`messages.privateDetailTitle`,{id:l.BoxID}),eyebrow:r(`messages.detailEyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/private`),children:[(0,H.jsx)(M,{size:15}),` `,r(`messages.backPrivate`)]}),children:(0,H.jsx)(We,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:r(`messages.ownerPeerTitle`,{owner:l.OwnerUserID,peer:l.PeerID})}),(0,H.jsx)(`div`,{className:`entity-subtitle`,children:r(`messages.senderSubtitle`,{sender:l.FromUserID,date:Be(l.Date)})})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[l.Deleted?(0,H.jsx)(G,{tone:`danger`,children:r(`common.deleted`)}):(0,H.jsx)(G,{children:r(`common.survived`)}),(0,H.jsxs)(G,{children:[`pts `,l.PTS]}),(0,H.jsx)(G,{children:l.Outgoing?r(`messages.outgoing`):r(`messages.incoming`)})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(J,{label:r(`messages.boxID`),value:String(l.BoxID),mono:!0}),(0,H.jsx)(J,{label:r(`messages.privateMessageID`),value:String(l.PrivateMessageID),mono:!0}),(0,H.jsx)(J,{label:r(`messages.messageSender`),value:String(l.MessageSenderID),mono:!0}),(0,H.jsx)(J,{label:r(`common.time`),value:Be(l.Date)})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.messageBox`),text:r(`messages.messageBoxesSnapshot`)}),(0,H.jsx)(Xe,{value:i.MessageJSON})]}),(0,H.jsxs)(`div`,{className:`raw-grid`,children:[(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.dialogRow`),text:r(`messages.dialogSnapshot`)}),(0,H.jsx)(Xe,{value:i.DialogJSON})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.privateRow`),text:r(`messages.privateSnapshot`)}),(0,H.jsx)(Xe,{value:i.PrivateJSON})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.userUpdateEvents`),text:r(`messages.userEventsSource`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:r(`common.count`)}),(0,H.jsx)(`th`,{children:r(`common.type`)}),(0,H.jsx)(`th`,{children:r(`common.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[i.UpdateEvents.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.PTS}),(0,H.jsx)(`td`,{children:e.PTSCount}),(0,H.jsx)(`td`,{children:e.Type}),(0,H.jsx)(`td`,{children:Be(e.Date)})]},`${e.PTS}-${e.Type}`)),i.UpdateEvents.length===0&&(0,H.jsx)(Je,{colSpan:4})]})]})})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.dispatchOutbox`),text:r(`messages.outboxSource`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:`ID`}),(0,H.jsx)(`th`,{children:r(`account.userID`)}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:r(`common.type`)}),(0,H.jsx)(`th`,{children:r(`common.status`)}),(0,H.jsx)(`th`,{children:r(`messages.attempts`)}),(0,H.jsx)(`th`,{children:r(`common.updatedAt`)})]})}),(0,H.jsxs)(`tbody`,{children:[i.Outbox.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.ID}),(0,H.jsx)(`td`,{children:e.TargetUserID}),(0,H.jsx)(`td`,{children:e.PTS}),(0,H.jsx)(`td`,{children:e.EventType}),(0,H.jsx)(`td`,{children:e.Status}),(0,H.jsx)(`td`,{children:e.Attempts}),(0,H.jsx)(`td`,{children:ze(e.UpdatedAt)})]},e.ID)),i.Outbox.length===0&&(0,H.jsx)(Je,{colSpan:7})]})]})})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:r(`common.operations`)}),(0,H.jsx)($e,{label:r(`messages.deleteThis`),icon:(0,H.jsx)(he,{size:15}),path:`/api/actions/delete-messages`,payload:()=>({owner_user_id:l.OwnerUserID,peer_id:l.PeerID,ids:[l.BoxID],revoke:!0}),onDone:c})]})})})}function ht({navigate:e}){let{t}=Ce(),[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(``),[u,d]=(0,g.useState)(`100`),[f,p]=(0,g.useState)(``),[m,h]=(0,g.useState)(!0),[_,v]=(0,g.useState)(!1),[y,S]=(0,g.useState)(``),[C,w]=(0,g.useState)(`1`),[T,E]=(0,g.useState)(null),[D,O]=(0,g.useState)(``);async function k(e=!1){if(O(``),!n||!i){O(t(`messages.selectPrivatePeers`));return}let r=new URLSearchParams({owner_user_id:String(n.ID),peer_id:String(i.ID),limit:u});if(e&&T?.rows.length){let e=T.rows[T.rows.length-1];r.set(`before_date`,String(e.Date)),r.set(`before_id`,String(e.BoxID)),s(String(e.Date)),l(String(e.BoxID))}else o&&r.set(`before_date`,o),c&&r.set(`before_id`,c);try{E(await x.messages(r))}catch(e){O(b(e))}}function A(e){r(e),s(``),l(``),E(null)}function j(e){a(e),s(``),l(``),E(null)}return(0,H.jsxs)(He,{title:t(`messages.privateTitle`),eyebrow:t(`messages.privateEyebrow`),children:[D&&(0,H.jsx)(Ke,{children:D}),(0,H.jsxs)(Ue,{children:[(0,H.jsxs)(`div`,{className:`message-selector-grid`,children:[(0,H.jsx)(dt,{label:t(`messages.ownerUser`),value:n,onChange:A}),(0,H.jsx)(dt,{label:t(`messages.peerUser`),value:i,onChange:j})]}),(0,H.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),k(!1)},children:[(0,H.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:t(`messages.beforeDatePlaceholder`)}),(0,H.jsx)(`input`,{value:c,onChange:e=>l(e.target.value),placeholder:t(`messages.beforeIDPlaceholder`)}),(0,H.jsx)(`input`,{className:`small-input`,value:u,onChange:e=>d(e.target.value),placeholder:t(`messages.limitPlaceholder`)}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,H.jsx)(B,{size:15}),` `,t(`messages.searchMessages`)]}),T?.rows.length?(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>k(!0),children:[(0,H.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]}):null]})]}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(q,{label:t(`messages.currentPage`),value:String(T?.rows.length??0)}),(0,H.jsx)(q,{label:t(`messages.deleted`),value:String((T?.rows??[]).filter(e=>e.Deleted).length),tone:`danger`}),(0,H.jsx)(q,{label:t(`messages.outgoing`),value:String((T?.rows??[]).filter(e=>e.Outgoing).length)}),(0,H.jsx)(q,{label:t(`messages.ownerPeer`),value:n&&i?`${Le(n)} / ${Le(i)}`:`-`})]}),(0,H.jsxs)(`div`,{className:`operation-row`,children:[(0,H.jsxs)(`div`,{className:`operation-box`,children:[(0,H.jsxs)(`div`,{className:`operation-title`,children:[(0,H.jsx)(he,{size:15}),` `,t(`messages.deleteSelected`)]}),(0,H.jsx)(`input`,{value:f,onChange:e=>p(e.target.value),placeholder:t(`messages.idsPlaceholder`)}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:m,onChange:e=>h(e.target.checked)}),` `,t(`messages.revoke`)]}),(0,H.jsx)($e,{path:`/api/actions/delete-messages`,label:t(`messages.previewDelete`),payload:()=>({owner_user_id:n?.ID??0,peer_id:i?.ID??0,ids:Ve(f,t(`messages.msgIDsInvalid`)),revoke:m})})]}),(0,H.jsxs)(`div`,{className:`operation-box`,children:[(0,H.jsxs)(`div`,{className:`operation-title`,children:[(0,H.jsx)(ie,{size:15}),` `,t(`messages.clearHistory`)]}),(0,H.jsx)(`input`,{value:y,onChange:e=>S(e.target.value),placeholder:t(`messages.maxIDPlaceholder`)}),(0,H.jsx)(`input`,{value:C,onChange:e=>w(e.target.value),placeholder:t(`messages.maxBatchesPlaceholder`)}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:m,onChange:e=>h(e.target.checked)}),` `,t(`messages.revoke`)]}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:_,onChange:e=>v(e.target.checked)}),` `,t(`messages.justClear`)]}),(0,H.jsx)($e,{path:`/api/actions/delete-history`,label:t(`messages.previewClearHistory`),payload:()=>({owner_user_id:n?.ID??0,peer_id:i?.ID??0,max_id:W(y),max_batches:W(C),just_clear:_,revoke:m})})]})]}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`common.messageId`)}),(0,H.jsx)(`th`,{children:t(`common.time`)}),(0,H.jsx)(`th`,{children:t(`common.sender`)}),(0,H.jsx)(`th`,{children:t(`messages.direction`)}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:t(`common.status`)}),(0,H.jsx)(`th`,{children:t(`messages.body`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[T?.rows.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.BoxID}),(0,H.jsx)(`td`,{children:Be(n.Date)}),(0,H.jsx)(`td`,{className:`mono`,children:n.FromUserID}),(0,H.jsx)(`td`,{children:n.Outgoing?t(`messages.outgoing`):t(`messages.incoming`)}),(0,H.jsx)(`td`,{children:n.PTS}),(0,H.jsx)(`td`,{children:n.Deleted?(0,H.jsx)(G,{tone:`danger`,children:t(`common.deleted`)}):(0,H.jsx)(G,{children:t(`common.survived`)})}),(0,H.jsx)(`td`,{className:`truncate`,children:n.Body}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/private/detail?owner_user_id=${n.OwnerUserID}&msg_id=${n.BoxID}`),children:[t(`common.detail`),` `,(0,H.jsx)(I,{size:14})]})})]},`${n.OwnerUserID}-${n.BoxID}`)),(!T||T.rows.length===0)&&(0,H.jsx)(Je,{colSpan:8})]})]})})]})}var gt=c(o(((e,t)=>{typeof document<`u`&&typeof navigator<`u`&&(function(n,r){typeof e==`object`&&t!==void 0?t.exports=r():typeof define==`function`&&define.amd?define(r):(n=typeof globalThis<`u`?globalThis:n||self,n.lottie=r())})(e,(function(){var n=``,r=!1,i=-999999,a=function(e){r=!!e},o=function(){return r},s=function(e){n=e},c=function(){return n};function l(e){return document.createElement(e)}function u(e,t){var n,r=e.length,i;for(n=0;n1?n[1]=1:n[1]<=0&&(n[1]=0),L(n[0],n[1],n[2])}function te(e,t){var n=ee(e[0]*255,e[1]*255,e[2]*255);return n[2]+=t,n[2]>1?n[2]=1:n[2]<0&&(n[2]=0),L(n[0],n[1],n[2])}function ne(e,t){var n=ee(e[0]*255,e[1]*255,e[2]*255);return n[0]+=t/360,n[0]>1?--n[0]:n[0]<0&&(n[0]+=1),L(n[0],n[1],n[2])}(function(){var e=[],t,n;for(t=0;t<256;t+=1)n=t.toString(16),e[t]=n.length===1?`0`+n:n;return function(t,n,r){return t<0&&(t=0),n<0&&(n=0),r<0&&(r=0),`#`+e[t]+e[n]+e[r]}})();var re=function(e){g=!!e},ie=function(){return g},ae=function(e){_=e},oe=function(){return _},se=function(){return v},ce=function(e){E=e},le=function(){return E},ue=function(e){y=e};function z(e){return document.createElementNS(`http://www.w3.org/2000/svg`,e)}function de(e){"@babel/helpers - typeof";return de=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},de(e)}var B=function(){var e=1,t=[],n,r,i={onmessage:function(){},postMessage:function(e){n({data:e})}},a={postMessage:function(e){i.onmessage({data:e})}};function s(e){if(window.Worker&&window.Blob&&o()){var t=new Blob([`var _workerSelf = self; self.onmessage = `,e.toString()],{type:`text/javascript`}),r=URL.createObjectURL(t);return new Worker(r)}return n=e,i}function c(){r||(r=s(function(e){function t(){function e(t,n){var o,s,c=t.length,l,u,d,f;for(s=0;s=0;--t)if(e[t].ty===`sh`)if(e[t].ks.k.i)a(e[t].ks.k);else for(o=e[t].ks.k.length,r=0;rn[0]?!0:n[0]>e[0]?!1:e[1]>n[1]?!0:n[1]>e[1]?!1:e[2]>n[2]?!0:n[2]>e[2]?!1:null}var s=function(){var e=[4,4,14];function t(e){var t=e.t.d;e.t.d={k:[{s:t,t:0}]}}function n(e){var n,r=e.length;for(n=0;n=0;--n)if(e[n].ty===`sh`)if(e[n].ks.k.i)e[n].ks.k.c=e[n].closed;else for(a=e[n].ks.k.length,i=0;i500)&&(this._imageLoaded(),clearInterval(n)),t+=1}.bind(this),50)}function a(t){var n=r(t,this.assetsPath,this.path),i=z(`image`);b?this.testImageLoaded(i):i.addEventListener(`load`,this._imageLoaded,!1),i.addEventListener(`error`,function(){a.img=e,this._imageLoaded()}.bind(this),!1),i.setAttributeNS(`http://www.w3.org/1999/xlink`,`href`,n),this._elementHelper.append?this._elementHelper.append(i):this._elementHelper.appendChild(i);var a={img:i,assetData:t};return a}function o(t){var n=r(t,this.assetsPath,this.path),i=l(`img`);i.crossOrigin=`anonymous`,i.addEventListener(`load`,this._imageLoaded,!1),i.addEventListener(`error`,function(){a.img=e,this._imageLoaded()}.bind(this),!1),i.src=n;var a={img:i,assetData:t};return a}function s(e){var t={assetData:e},n=r(e,this.assetsPath,this.path);return B.loadData(n,function(e){t.img=e,this._footageLoaded()}.bind(this),function(){t.img={},this._footageLoaded()}.bind(this)),t}function c(e,t){this.imagesLoadedCb=t;var n,r=e.length;for(n=0;nthis.animationData.op&&(this.animationData.op=e.op,this.totalFrames=Math.floor(e.op-this.animationData.ip));var t=this.animationData.layers,n,r=t.length,i=e.layers,a,o=i.length;for(a=0;athis.timeCompleted&&(this.currentFrame=this.timeCompleted),this.trigger(`enterFrame`),this.renderFrame(),this.trigger(`drawnFrame`)},H.prototype.renderFrame=function(){if(!(this.isLoaded===!1||!this.renderer))try{this.expressionsPlugin&&this.expressionsPlugin.resetFrame(),this.renderer.renderFrame(this.currentFrame+this.firstFrame)}catch(e){this.triggerRenderFrameError(e)}},H.prototype.play=function(e){e&&this.name!==e||this.isPaused===!0&&(this.isPaused=!1,this.trigger(`_play`),this.audioController.resume(),this._idle&&(this._idle=!1,this.trigger(`_active`)))},H.prototype.pause=function(e){e&&this.name!==e||this.isPaused===!1&&(this.isPaused=!0,this.trigger(`_pause`),this._idle=!0,this.trigger(`_idle`),this.audioController.pause())},H.prototype.togglePause=function(e){e&&this.name!==e||(this.isPaused===!0?this.play():this.pause())},H.prototype.stop=function(e){e&&this.name!==e||(this.pause(),this.playCount=0,this._completedLoop=!1,this.setCurrentRawFrameValue(0))},H.prototype.getMarkerData=function(e){for(var t,n=0;n=this.totalFrames-1&&this.frameModifier>0?!this.loop||this.playCount===this.loop?this.checkSegments(t>this.totalFrames?t%this.totalFrames:0)||(n=!0,t=this.totalFrames-1):t>=this.totalFrames?(this.playCount+=1,this.checkSegments(t%this.totalFrames)||(this.setCurrentRawFrameValue(t%this.totalFrames),this._completedLoop=!0,this.trigger(`loopComplete`))):this.setCurrentRawFrameValue(t):t<0?this.checkSegments(t%this.totalFrames)||(this.loop&&!(this.playCount--<=0&&this.loop!==!0)?(this.setCurrentRawFrameValue(this.totalFrames+t%this.totalFrames),this._completedLoop?this.trigger(`loopComplete`):this._completedLoop=!0):(n=!0,t=0)):this.setCurrentRawFrameValue(t),n&&(this.setCurrentRawFrameValue(t),this.pause(),this.trigger(`complete`))}},H.prototype.adjustSegment=function(e,t){this.playCount=0,e[1]0&&(this.playSpeed<0?this.setSpeed(-this.playSpeed):this.setDirection(-1)),this.totalFrames=e[0]-e[1],this.timeCompleted=this.totalFrames,this.firstFrame=e[1],this.setCurrentRawFrameValue(this.totalFrames-.001-t)):e[1]>e[0]&&(this.frameModifier<0&&(this.playSpeed<0?this.setSpeed(-this.playSpeed):this.setDirection(1)),this.totalFrames=e[1]-e[0],this.timeCompleted=this.totalFrames,this.firstFrame=e[0],this.setCurrentRawFrameValue(.001+t)),this.trigger(`segmentStart`)},H.prototype.setSegment=function(e,t){var n=-1;this.isPaused&&(this.currentRawFrame+this.firstFramet&&(n=t-e)),this.firstFrame=e,this.totalFrames=t-e,this.timeCompleted=this.totalFrames,n!==-1&&this.goToAndStop(n,!0)},H.prototype.playSegments=function(e,t){if(t&&(this.segments.length=0),ye(e[0])===`object`){var n,r=e.length;for(n=0;n=0;--n)t[n].animation.destroy(e)}function T(e,t,n){var r=[].concat([].slice.call(document.getElementsByClassName(`lottie`)),[].slice.call(document.getElementsByClassName(`bodymovin`))),i,a=r.length;for(i=0;i0?n=c:t=c;while(Math.abs(s)>a&&++l=i?g(e,d,t,n):f===0?d:h(e,a,a+c,t,n)}},e}(),xe=function(){function e(e){return e.concat(m(e.length))}return{double:e}}(),Se=function(){return function(e,t,n){var r=0,i=e,a=m(i),o={newElement:s,release:c};function s(){var e;return r?(--r,e=a[r]):e=t(),e}function c(e){r===i&&(a=xe.double(a),i*=2),n&&n(e),a[r]=e,r+=1}return o}}(),Ce=function(){function e(){return{addedLength:0,percents:p(`float32`,le()),lengths:p(`float32`,le())}}return Se(8,e)}(),we=function(){function e(){return{lengths:[],totalLength:0}}function t(e){var t,n=e.lengths.length;for(t=0;t-.001&&o<.001}function n(n,r,i,a,o,s,c,l,u){if(i===0&&s===0&&u===0)return t(n,r,a,o,c,l);var d=e.sqrt(e.pow(a-n,2)+e.pow(o-r,2)+e.pow(s-i,2)),f=e.sqrt(e.pow(c-n,2)+e.pow(l-r,2)+e.pow(u-i,2)),p=e.sqrt(e.pow(c-a,2)+e.pow(l-o,2)+e.pow(u-s,2)),m=d>f?d>p?d-f-p:p-f-d:p>f?p-f-d:f-d-p;return m>-1e-4&&m<1e-4}var r=function(){return function(e,t,n,r){var i=le(),a,o,s,c,l,u=0,d,f=[],p=[],m=Ce.newElement();for(s=n.length,a=0;ao?-1:1,l=!0;l;)if(r[a]<=o&&r[a+1]>o?(s=(o-r[a])/(r[a+1]-r[a]),l=!1):a+=c,a<0||a>=i-1){if(a===i-1)return n[a];l=!1}return n[a]+(n[a+1]-n[a])*s}function l(t,n,r,i,a,o){var s=c(a,o),l=1-s;return[e.round((l*l*l*t[0]+(s*l*l+l*s*l+l*l*s)*r[0]+(s*s*l+l*s*s+s*l*s)*i[0]+s*s*s*n[0])*1e3)/1e3,e.round((l*l*l*t[1]+(s*l*l+l*s*l+l*l*s)*r[1]+(s*s*l+l*s*s+s*l*s)*i[1]+s*s*s*n[1])*1e3)/1e3]}var u=p(`float32`,8);function d(t,n,r,i,a,o,s){a<0?a=0:a>1&&(a=1);var l=c(a,s);o=o>1?1:o;var d=c(o,s),f,p=t.length,m=1-l,h=1-d,g=m*m*m,_=l*m*m*3,v=l*l*m*3,y=l*l*l,b=m*m*h,x=l*m*h+m*l*h+m*m*d,S=l*l*h+m*l*d+l*m*d,C=l*l*d,w=m*h*h,T=l*h*h+m*d*h+m*h*d,E=l*d*h+m*d*d+l*h*d,D=l*d*d,O=h*h*h,k=d*h*h+h*d*h+h*h*d,A=d*d*h+h*d*d+d*h*d,j=d*d*d;for(f=0;f=l.t-n){c.h&&(c=l),i=0;break}if(l.t-n>e){i=a;break}a=v||e=v?x.points.length-1:0;for(f=x.points[S].point.length,d=0;d=T&&C=v)r[0]=b[0],r[1]=b[1],r[2]=b[2];else if(e<=y)r[0]=c.s[0],r[1]=c.s[1],r[2]=c.s[2];else{var j=Me(c.s),M=Me(b),N=(e-y)/(v-y);je(r,Ae(j,M,N))}else for(a=0;a=v?m=1:e1e-6?(f=Math.acos(p),m=Math.sin(f),h=Math.sin((1-n)*f)/m,g=Math.sin(n*f)/m):(h=1-n,g=n),r[0]=h*i+g*c,r[1]=h*a+g*l,r[2]=h*o+g*u,r[3]=h*s+g*d,r}function je(e,t){var n=t[0],r=t[1],i=t[2],a=t[3],o=Math.atan2(2*r*a-2*n*i,1-2*r*r-2*i*i),s=Math.asin(2*n*r+2*i*a),c=Math.atan2(2*n*a-2*r*i,1-2*n*n-2*i*i);e[0]=o/D,e[1]=s/D,e[2]=c/D}function Me(e){var t=e[0]*D,n=e[1]*D,r=e[2]*D,i=Math.cos(t/2),a=Math.cos(n/2),o=Math.cos(r/2),s=Math.sin(t/2),c=Math.sin(n/2),l=Math.sin(r/2),u=i*a*o-s*c*l;return[s*c*o+i*a*l,s*a*o+i*c*l,i*c*o-s*a*l,u]}function Ne(){var e=this.comp.renderedFrame-this.offsetTime,t=this.keyframes[0].t-this.offsetTime,n=this.keyframes[this.keyframes.length-1].t-this.offsetTime;if(!(e===this._caching.lastFrame||this._caching.lastFrame!==De&&(this._caching.lastFrame>=n&&e>=n||this._caching.lastFrame=e&&(this._caching._lastKeyframeIndex=-1,this._caching.lastIndex=0);var r=this.interpolateValue(e,this._caching);this.pv=r}return this._caching.lastFrame=e,this.pv}function Pe(e){var t;if(this.propType===`unidimensional`)t=e*this.mult,Oe(this.v-t)>1e-5&&(this.v=t,this._mdf=!0);else for(var n=0,r=this.v.length;n1e-5&&(this.v[n]=t,this._mdf=!0),n+=1}function Fe(){if(!(this.elem.globalData.frameId===this.frameId||!this.effectsSequence.length)){if(this.lock){this.setVValue(this.pv);return}this.lock=!0,this._mdf=this._isFirstFrame;var e,t=this.effectsSequence.length,n=this.kf?this.pv:this.data.k;for(e=0;e=this._maxLength&&this.doubleArrayLength(),n){case`v`:a=this.v;break;case`i`:a=this.i;break;case`o`:a=this.o;break;default:a=[];break}(!a[r]||a[r]&&!i)&&(a[r]=He.newElement()),a[r][0]=e,a[r][1]=t},Ue.prototype.setTripleAt=function(e,t,n,r,i,a,o,s){this.setXYAt(e,t,`v`,o,s),this.setXYAt(n,r,`o`,o,s),this.setXYAt(i,a,`i`,o,s)},Ue.prototype.reverse=function(){var e=new Ue;e.setPathData(this.c,this._length);var t=this.v,n=this.o,r=this.i,i=0;this.c&&(e.setTripleAt(t[0][0],t[0][1],r[0][0],r[0][1],n[0][0],n[0][1],0,!1),i=1);var a=this._length-1,o=this._length,s;for(s=i;s=p[p.length-1].t-this.offsetTime)i=p[p.length-1].s?p[p.length-1].s[0]:p[p.length-2].e[0],o=!0;else{for(var m=r,h=p.length-1,g=!0,_,v,y;g&&(_=p[m],v=p[m+1],!(v.t-this.offsetTime>e));)m=v.t-this.offsetTime)d=1;else if(e<_.t-this.offsetTime)d=0;else{var b;y.__fnct?b=y.__fnct:(b=be.getBezierEasing(_.o.x,_.o.y,_.i.x,_.i.y).get,y.__fnct=b),d=b((e-(_.t-this.offsetTime))/(v.t-this.offsetTime-(_.t-this.offsetTime)))}a=v.s?v.s[0]:_.e[0]}i=_.s[0]}for(l=t._length,u=i.i[0].length,n.lastIndex=r,s=0;sr&&t>r)||(this._caching.lastIndex=i0||e>-1e-6&&e<0?r(e*t)/t:e}function P(){var e=this.props,t=N(e[0]),n=N(e[1]),r=N(e[4]),i=N(e[5]),a=N(e[12]),o=N(e[13]);return`matrix(`+t+`,`+n+`,`+r+`,`+i+`,`+a+`,`+o+`)`}return function(){this.reset=i,this.rotate=a,this.rotateX=o,this.rotateY=s,this.rotateZ=c,this.skew=u,this.skewFromAxis=d,this.shear=l,this.scale=f,this.setTransform=m,this.translate=h,this.transform=g,this.multiply=_,this.applyToPoint=S,this.applyToX=C,this.applyToY=w,this.applyToZ=T,this.applyToPointArray=A,this.applyToTriplePoints=k,this.applyToPointStringified=j,this.toCSS=M,this.to2dCSS=P,this.clone=b,this.cloneFromProps=x,this.equals=y,this.inversePoints=O,this.inversePoint=D,this.getInverseMatrix=E,this._t=this.transform,this.isIdentity=v,this._identity=!0,this._identityCalculated=!1,this.props=p(`float32`,16),this.reset()}}();function q(e){"@babel/helpers - typeof";return q=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},q(e)}var J={},qe=`__[STANDALONE]__`,Je=`__[ANIMATIONDATA]__`,Ye=``;function Xe(e){s(e)}function Ze(){qe===!0?U.searchAnimations(Je,qe,Ye):U.searchAnimations()}function Qe(e){re(e)}function $e(e){ue(e)}function et(e){return qe===!0&&(e.animationData=JSON.parse(Je)),U.loadAnimation(e)}function tt(e){if(typeof e==`string`)switch(e){case`high`:ce(200);break;default:case`medium`:ce(50);break;case`low`:ce(10);break}else!isNaN(e)&&e>1&&ce(e)}function nt(){return typeof navigator<`u`}function rt(e,t){e===`expressions`&&ae(t)}function it(e){switch(e){case`propertyFactory`:return W;case`shapePropertyFactory`:return G;case`matrix`:return K;default:return null}}J.play=U.play,J.pause=U.pause,J.setLocationHref=Xe,J.togglePause=U.togglePause,J.setSpeed=U.setSpeed,J.setDirection=U.setDirection,J.stop=U.stop,J.searchAnimations=Ze,J.registerAnimation=U.registerAnimation,J.loadAnimation=et,J.setSubframeRendering=Qe,J.resize=U.resize,J.goToAndStop=U.goToAndStop,J.destroy=U.destroy,J.setQuality=tt,J.inBrowser=nt,J.installPlugin=rt,J.freeze=U.freeze,J.unfreeze=U.unfreeze,J.setVolume=U.setVolume,J.mute=U.mute,J.unmute=U.unmute,J.getRegisteredAnimations=U.getRegisteredAnimations,J.useWebWorker=a,J.setIDPrefix=$e,J.__getFactory=it,J.version=`5.13.0`;function at(){document.readyState===`complete`&&(clearInterval(ut),Ze())}function ot(e){for(var t=st.split(`&`),n=0;n=1?a.push({s:e-1,e:t-1}):(a.push({s:e,e:1}),a.push({s:0,e:t-1}));var o=[],s,c=a.length,l;for(s=0;sr+n)){var u=l.s*i<=r?0:(l.s*i-r)/n,d=l.e*i>=r+n?1:(l.e*i-r)/n;o.push([u,d])}return o.length||o.push([0,0]),o},pt.prototype.releasePathsData=function(e){var t,n=e.length;for(t=0;t1?1+r:this.s.v<0?0+r:this.s.v+r,n=this.e.v>1?1+r:this.e.v<0?0+r:this.e.v+r,t>n){var i=t;t=n,n=i}t=Math.round(t*1e4)*1e-4,n=Math.round(n*1e4)*1e-4,this.sValue=t,this.eValue=n}else t=this.sValue,n=this.eValue;var a,o,s=this.shapes.length,c,l,u,d,f,p=0;if(n===t)for(o=0;o=0;--o)if(h=this.shapes[o],h.shape._mdf){for(g=h.localShapeCollection,g.releaseShapes(),this.m===2&&s>1?(b=this.calculateShapeEdges(t,n,h.totalShapeLength,y,p),y+=h.totalShapeLength):b=[[_,v]],l=b.length,c=0;c=1?m.push({s:h.totalShapeLength*(_-1),e:h.totalShapeLength*(v-1)}):(m.push({s:h.totalShapeLength*_,e:h.totalShapeLength}),m.push({s:0,e:h.totalShapeLength*(v-1)}));var x=this.addShapes(h,m[0]);if(m[0].s!==m[0].e){if(m.length>1)if(h.shape.paths.shapes[h.shape.paths._length-1].c){var S=x.pop();this.addPaths(x,g),x=this.addShapes(h,m[1],S)}else this.addPaths(x,g),x=this.addShapes(h,m[1]);this.addPaths(x,g)}}h.shape.paths=g}}else if(this._mdf)for(o=0;ot.e){n.c=!1;break}else t.s<=l&&t.e>=l+u.addedLength?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[s],i[a].v[s],n,d,g),g=!1):(p=Ee.getNewSegment(i[a].v[s-1],i[a].v[s],i[a].o[s-1],i[a].i[s],(t.s-l)/u.addedLength,(t.e-l)/u.addedLength,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1),l+=u.addedLength,d+=1;if(i[a].c&&f.length){if(u=f[s-1],l<=t.e){var _=f[s-1].addedLength;t.s<=l&&t.e>=l+_?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[0],i[a].v[0],n,d,g),g=!1):(p=Ee.getNewSegment(i[a].v[s-1],i[a].v[0],i[a].o[s-1],i[a].i[0],(t.s-l)/_,(t.e-l)/_,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1)}else n.c=!1;l+=u.addedLength,d+=1}if(n._length&&(n.setXYAt(n.v[h][0],n.v[h][1],`i`,h),n.setXYAt(n.v[n._length-1][0],n.v[n._length-1][1],`o`,n._length-1)),l>t.e)break;a=this.p.keyframes[this.p.keyframes.length-1].t?(r=this.p.getValueAtTime(this.p.keyframes[this.p.keyframes.length-1].t/n,0),i=this.p.getValueAtTime((this.p.keyframes[this.p.keyframes.length-1].t-.05)/n,0)):(r=this.p.pv,i=this.p.getValueAtTime((this.p._caching.lastFrame+this.p.offsetTime-.01)/n,this.p.offsetTime));else if(this.px&&this.px.keyframes&&this.py.keyframes&&this.px.getValueAtTime&&this.py.getValueAtTime){r=[],i=[];var a=this.px,o=this.py;a._caching.lastFrame+a.offsetTime<=a.keyframes[0].t?(r[0]=a.getValueAtTime((a.keyframes[0].t+.01)/n,0),r[1]=o.getValueAtTime((o.keyframes[0].t+.01)/n,0),i[0]=a.getValueAtTime(a.keyframes[0].t/n,0),i[1]=o.getValueAtTime(o.keyframes[0].t/n,0)):a._caching.lastFrame+a.offsetTime>=a.keyframes[a.keyframes.length-1].t?(r[0]=a.getValueAtTime(a.keyframes[a.keyframes.length-1].t/n,0),r[1]=o.getValueAtTime(o.keyframes[o.keyframes.length-1].t/n,0),i[0]=a.getValueAtTime((a.keyframes[a.keyframes.length-1].t-.01)/n,0),i[1]=o.getValueAtTime((o.keyframes[o.keyframes.length-1].t-.01)/n,0)):(r=[a.pv,o.pv],i[0]=a.getValueAtTime((a._caching.lastFrame+a.offsetTime-.01)/n,a.offsetTime),i[1]=o.getValueAtTime((o._caching.lastFrame+o.offsetTime-.01)/n,o.offsetTime))}else i=e,r=i;this.v.rotate(-Math.atan2(r[1]-i[1],r[0]-i[0]))}this.data.p&&this.data.p.s?this.data.p.z?this.v.translate(this.px.v,this.py.v,-this.pz.v):this.v.translate(this.px.v,this.py.v,0):this.v.translate(this.p.v[0],this.p.v[1],-this.p.v[2])}this.frameId=this.elem.globalData.frameId}}function r(){if(this.appliedTransformations=0,this.pre.reset(),!this.a.effectsSequence.length)this.pre.translate(-this.a.v[0],-this.a.v[1],this.a.v[2]),this.appliedTransformations=1;else return;if(!this.s.effectsSequence.length)this.pre.scale(this.s.v[0],this.s.v[1],this.s.v[2]),this.appliedTransformations=2;else return;if(this.sk)if(!this.sk.effectsSequence.length&&!this.sa.effectsSequence.length)this.pre.skewFromAxis(-this.sk.v,this.sa.v),this.appliedTransformations=3;else return;this.r?this.r.effectsSequence.length||(this.pre.rotate(-this.r.v),this.appliedTransformations=4):!this.rz.effectsSequence.length&&!this.ry.effectsSequence.length&&!this.rx.effectsSequence.length&&!this.or.effectsSequence.length&&(this.pre.rotateZ(-this.rz.v).rotateY(this.ry.v).rotateX(this.rx.v).rotateZ(-this.or.v[2]).rotateY(this.or.v[1]).rotateX(this.or.v[0]),this.appliedTransformations=4)}function i(){}function a(e){this._addDynamicProperty(e),this.elem.addDynamicProperty(e),this._isDirty=!0}function o(e,t,n){if(this.elem=e,this.frameId=-1,this.propType=`transform`,this.data=t,this.v=new K,this.pre=new K,this.appliedTransformations=0,this.initDynamicPropertyContainer(n||e),t.p&&t.p.s?(this.px=W.getProp(e,t.p.x,0,0,this),this.py=W.getProp(e,t.p.y,0,0,this),t.p.z&&(this.pz=W.getProp(e,t.p.z,0,0,this))):this.p=W.getProp(e,t.p||{k:[0,0,0]},1,0,this),t.rx){if(this.rx=W.getProp(e,t.rx,0,D,this),this.ry=W.getProp(e,t.ry,0,D,this),this.rz=W.getProp(e,t.rz,0,D,this),t.or.k[0].ti){var r,i=t.or.k.length;for(r=0;r0;)--n,this._elements.unshift(t[n]);this.dynamicProperties.length?this.k=!0:this.getValue(!0)},gt.prototype.resetElements=function(e){var t,n=e.length;for(t=0;t0?Math.floor(f):Math.ceil(f),h=this.pMatrix.props,g=this.rMatrix.props,_=this.sMatrix.props;this.pMatrix.reset(),this.rMatrix.reset(),this.sMatrix.reset(),this.tMatrix.reset(),this.matrix.reset();var v=0;if(f>0){for(;vm;)this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,1,!0),--v;p&&(this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,-p,!0),v-=p)}r=this.data.m===1?0:this._currentCopies-1,i=this.data.m===1?1:-1,a=this._currentCopies;for(var y,b;a;){if(t=this.elemsData[r].it,n=t[t.length-1].transform.mProps.v.props,b=n.length,t[t.length-1].transform.mProps._mdf=!0,t[t.length-1].transform.op._mdf=!0,t[t.length-1].transform.op.v=this._currentCopies===1?this.so.v:this.so.v+(this.eo.v-this.so.v)*(r/(this._currentCopies-1)),v!==0){for((r!==0&&i===1||r!==this._currentCopies-1&&i===-1)&&this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,1,!1),this.matrix.transform(g[0],g[1],g[2],g[3],g[4],g[5],g[6],g[7],g[8],g[9],g[10],g[11],g[12],g[13],g[14],g[15]),this.matrix.transform(_[0],_[1],_[2],_[3],_[4],_[5],_[6],_[7],_[8],_[9],_[10],_[11],_[12],_[13],_[14],_[15]),this.matrix.transform(h[0],h[1],h[2],h[3],h[4],h[5],h[6],h[7],h[8],h[9],h[10],h[11],h[12],h[13],h[14],h[15]),y=0;y0&&r<1?[t]:[]:[t-r,t+r].filter(function(e){return e>0&&e<1})},Tt.prototype.split=function(e){if(e<=0)return[wt(this.points[0]),this];if(e>=1)return[this,wt(this.points[this.points.length-1])];var t=xt(this.points[0],this.points[1],e),n=xt(this.points[1],this.points[2],e),r=xt(this.points[2],this.points[3],e),i=xt(t,n,e),a=xt(n,r,e),o=xt(i,a,e);return[new Tt(this.points[0],t,i,o,!0),new Tt(o,a,r,this.points[3],!0)]};function Et(e,t){var n=e.points[0][t],r=e.points[e.points.length-1][t];if(n>r){var i=r;r=n,n=i}for(var a=St(3*e.a[t],2*e.b[t],e.c[t]),o=0;o0&&a[o]<1){var s=e.point(a[o])[t];sr&&(r=s)}return{min:n,max:r}}Tt.prototype.bounds=function(){return{x:Et(this,0),y:Et(this,1)}},Tt.prototype.boundingBox=function(){var e=this.bounds();return{left:e.x.min,right:e.x.max,top:e.y.min,bottom:e.y.max,width:e.x.max-e.x.min,height:e.y.max-e.y.min,cx:(e.x.max+e.x.min)/2,cy:(e.y.max+e.y.min)/2}};function Dt(e,t,n){var r=e.boundingBox();return{cx:r.cx,cy:r.cy,width:r.width,height:r.height,bez:e,t:(t+n)/2,t1:t,t2:n}}function Ot(e){var t=e.bez.split(.5);return[Dt(t[0],e.t1,e.t),Dt(t[1],e.t,e.t2)]}function kt(e,t){return Math.abs(e.cx-t.cx)*2=a||e.width<=r&&e.height<=r&&t.width<=r&&t.height<=r){i.push([e.t,t.t]);return}var o=Ot(e),s=Ot(t);At(o[0],s[0],n+1,r,i,a),At(o[0],s[1],n+1,r,i,a),At(o[1],s[0],n+1,r,i,a),At(o[1],s[1],n+1,r,i,a)}}Tt.prototype.intersections=function(e,t,n){t===void 0&&(t=2),n===void 0&&(n=7);var r=[];return At(Dt(this,0,1),Dt(e,0,1),0,t,r,n),r},Tt.shapeSegment=function(e,t){var n=(t+1)%e.length();return new Tt(e.v[t],e.o[t],e.i[n],e.v[n],!0)},Tt.shapeSegmentInverted=function(e,t){var n=(t+1)%e.length();return new Tt(e.v[n],e.i[n],e.o[t],e.v[t],!0)};function jt(e,t){return[e[1]*t[2]-e[2]*t[1],e[2]*t[0]-e[0]*t[2],e[0]*t[1]-e[1]*t[0]]}function Mt(e,t,n,r){var i=[e[0],e[1],1],a=[t[0],t[1],1],o=[n[0],n[1],1],s=[r[0],r[1],1],c=jt(jt(i,a),jt(o,s));return yt(c[2])?null:[c[0]/c[2],c[1]/c[2]]}function Y(e,t,n){return[e[0]+Math.cos(t)*n,e[1]-Math.sin(t)*n]}function Nt(e,t){return Math.hypot(e[0]-t[0],e[1]-t[1])}function Pt(e,t){return vt(e[0],t[0])&&vt(e[1],t[1])}function Ft(){}u([ft],Ft),Ft.prototype.initModifierProperties=function(e,t){this.getValue=this.processKeys,this.amplitude=W.getProp(e,t.s,0,null,this),this.frequency=W.getProp(e,t.r,0,null,this),this.pointsType=W.getProp(e,t.pt,0,null,this),this._isAnimated=this.amplitude.effectsSequence.length!==0||this.frequency.effectsSequence.length!==0||this.pointsType.effectsSequence.length!==0};function It(e,t,n,r,i,a,o){var s=n-Math.PI/2,c=n+Math.PI/2,l=t[0]+Math.cos(n)*r*i,u=t[1]-Math.sin(n)*r*i;e.setTripleAt(l,u,l+Math.cos(s)*a,u-Math.sin(s)*a,l+Math.cos(c)*o,u-Math.sin(c)*o,e.length())}function Lt(e,t){var n=[t[0]-e[0],t[1]-e[1]],r=-Math.PI*.5;return[Math.cos(r)*n[0]-Math.sin(r)*n[1],Math.sin(r)*n[0]+Math.cos(r)*n[1]]}function Rt(e,t){var n=t===0?e.length()-1:t-1,r=(t+1)%e.length(),i=e.v[n],a=e.v[r],o=Lt(i,a);return Math.atan2(0,1)-Math.atan2(o[1],o[0])}function zt(e,t,n,r,i,a,o){var s=Rt(t,n),c=t.v[n%t._length],l=t.v[n===0?t._length-1:n-1],u=t.v[(n+1)%t._length],d=a===2?Math.sqrt((c[0]-l[0])**2+(c[1]-l[1])**2):0,f=a===2?Math.sqrt((c[0]-u[0])**2+(c[1]-u[1])**2):0;It(e,t.v[n%t._length],s,o,r,f/((i+1)*2),d/((i+1)*2),a)}function Bt(e,t,n,r,i,a){for(var o=0;o1&&t.length>1&&(i=Wt(e[0],t[t.length-1]),i)?[[e[0].split(i[0])[0]],[t[t.length-1].split(i[1])[1]]]:[n,r]}function Kt(e){for(var t,n=1;n1&&(t=Gt(e[e.length-1],e[0]),e[e.length-1]=t[0],e[0]=t[1]),e}function qt(e,t){var n=e.inflectionPoints(),r,i,a,o;if(n.length===0)return[Ht(e,t)];if(n.length===1||vt(n[1],1))return a=e.split(n[0]),r=a[0],i=a[1],[Ht(r,t),Ht(i,t)];a=e.split(n[0]),r=a[0];var s=(n[1]-n[0])/(1-n[0]);return a=a[1].split(s),o=a[0],i=a[1],[Ht(r,t),Ht(o,t),Ht(i,t)]}function Jt(){}u([ft],Jt),Jt.prototype.initModifierProperties=function(e,t){this.getValue=this.processKeys,this.amount=W.getProp(e,t.a,0,null,this),this.miterLimit=W.getProp(e,t.ml,0,null,this),this.lineJoin=t.lj,this._isAnimated=this.amount.effectsSequence.length!==0},Jt.prototype.processPath=function(e,t,n,r){var i=We.newElement();i.c=e.c;var a=e.length();e.c||--a;var o,s,c,l=[];for(o=0;o=0;--o)c=Tt.shapeSegmentInverted(e,o),l.push(qt(c,t));l=Kt(l);var u=null,d=null;for(o=0;o0&&(o=!1),o){var u=l(`style`);u.setAttribute(`f-forigin`,n[r].fOrigin),u.setAttribute(`f-origin`,n[r].origin),u.setAttribute(`f-family`,n[r].fFamily),u.type=`text/css`,u.innerText=`@font-face {font-family: `+n[r].fFamily+`; font-style: normal; src: url('`+n[r].fPath+`');}`,t.appendChild(u)}}else if(n[r].fOrigin===`g`||n[r].origin===1){for(s=document.querySelectorAll(`link[f-forigin="g"], link[f-origin="1"]`),c=0;c=55296&&n<=56319){var r=e.charCodeAt(1);r>=56320&&r<=57343&&(t=(n-55296)*1024+r-56320+65536)}return t}function S(e,t){var n=e.toString(16)+t.toString(16);return d.indexOf(n)!==-1}function C(e){return e===s}function w(e){return e===o}function T(e){var t=x(e);return t>=c&&t<=u}function E(e){return T(e.substr(0,2))&&T(e.substr(2,2))}function D(e){return t.indexOf(e)!==-1}function O(e,t){var o=x(e.substr(t,2));if(o!==n)return!1;var s=0;for(t+=2;s<5;){if(o=x(e.substr(t,2)),oa)return!1;s+=1,t+=2}return x(e.substr(t,2))===r}function k(){this.isLoaded=!0}var A=function(){this.fonts=[],this.chars=null,this.typekitLoaded=0,this.isLoaded=!1,this._warned=!1,this.initTime=Date.now(),this.setIsLoadedBinded=this.setIsLoaded.bind(this),this.checkLoadedFontsBinded=this.checkLoadedFonts.bind(this)};return A.isModifier=S,A.isZeroWidthJoiner=C,A.isFlagEmoji=E,A.isRegionalCode=T,A.isCombinedCharacter=D,A.isRegionalFlag=O,A.isVariationSelector=w,A.BLACK_FLAG_CODE_POINT=n,A.prototype={addChars:_,addFonts:g,getCharData:v,getFontByName:b,measureText:y,checkLoadedFonts:m,setIsLoaded:k},A}();function Zt(e){this.animationData=e}Zt.prototype.getProp=function(e){return this.animationData.slots&&this.animationData.slots[e.sid]?Object.assign(e,this.animationData.slots[e.sid].p):e};function Qt(e){return new Zt(e)}function $t(){}$t.prototype={initRenderable:function(){this.isInRange=!1,this.hidden=!1,this.isTransparent=!1,this.renderableComponents=[]},addRenderableComponent:function(e){this.renderableComponents.indexOf(e)===-1&&this.renderableComponents.push(e)},removeRenderableComponent:function(e){this.renderableComponents.indexOf(e)!==-1&&this.renderableComponents.splice(this.renderableComponents.indexOf(e),1)},prepareRenderableFrame:function(e){this.checkLayerLimits(e)},checkTransparency:function(){this.finalTransform.mProp.o.v<=0?!this.isTransparent&&this.globalData.renderConfig.hideOnTransparent&&(this.isTransparent=!0,this.hide()):this.isTransparent&&(this.isTransparent=!1,this.show())},checkLayerLimits:function(e){this.data.ip-this.data.st<=e&&this.data.op-this.data.st>e?this.isInRange!==!0&&(this.globalData._mdf=!0,this._mdf=!0,this.isInRange=!0,this.show()):this.isInRange!==!1&&(this.globalData._mdf=!0,this.isInRange=!1,this.hide())},renderRenderable:function(){var e,t=this.renderableComponents.length;for(e=0;e.1)&&this.audio.seek(this._currentTime/this.globalData.frameRate):(this.audio.play(),this.audio.seek(this._currentTime/this.globalData.frameRate),this._isPlaying=!0))},hn.prototype.show=function(){},hn.prototype.hide=function(){this.audio.pause(),this._isPlaying=!1},hn.prototype.pause=function(){this.audio.pause(),this._isPlaying=!1,this._canPlay=!1},hn.prototype.resume=function(){this._canPlay=!0},hn.prototype.setRate=function(e){this.audio.rate(e)},hn.prototype.volume=function(e){this._volumeMultiplier=e,this._previousVolume=e*this._volume,this.audio.volume(this._previousVolume)},hn.prototype.getBaseElement=function(){return null},hn.prototype.destroy=function(){},hn.prototype.sourceRectAtTime=function(){},hn.prototype.initExpressions=function(){};function gn(){}gn.prototype.checkLayers=function(e){var t,n=this.layers.length,r;for(this.completeLayers=!0,t=n-1;t>=0;--t)this.elements[t]||(r=this.layers[t],r.ip-r.st<=e-this.layers[t].st&&r.op-r.st>e-this.layers[t].st&&this.buildItem(t)),this.completeLayers=this.elements[t]?this.completeLayers:!1;this.checkPendingElements()},gn.prototype.createItem=function(e){switch(e.ty){case 2:return this.createImage(e);case 0:return this.createComp(e);case 1:return this.createSolid(e);case 3:return this.createNull(e);case 4:return this.createShape(e);case 5:return this.createText(e);case 6:return this.createAudio(e);case 13:return this.createCamera(e);case 15:return this.createFootage(e);default:return this.createNull(e)}},gn.prototype.createCamera=function(){throw Error(`You're using a 3d camera. Try the html renderer.`)},gn.prototype.createAudio=function(e){return new hn(e,this.globalData,this)},gn.prototype.createFootage=function(e){return new mn(e,this.globalData,this)},gn.prototype.buildAllItems=function(){var e,t=this.layers.length;for(e=0;e0&&(this.maskElement.setAttribute(`id`,p),this.element.maskedElement.setAttribute(b,`url(`+c()+`#`+p+`)`),r.appendChild(this.maskElement)),this.viewData.length&&this.element.addRenderableComponent(this)}yn.prototype.getMaskProperty=function(e){return this.viewData[e].prop},yn.prototype.renderFrame=function(e){var t=this.element.finalTransform.mat,n,r=this.masksProperties.length;for(n=0;n1&&(r+=` C`+t.o[i-1][0]+`,`+t.o[i-1][1]+` `+t.i[0][0]+`,`+t.i[0][1]+` `+t.v[0][0]+`,`+t.v[0][1]),n.lastPath!==r){var o=``;n.elem&&(t.c&&(o=e.inv?this.solidPath+r:r),n.elem.setAttribute(`d`,o)),n.lastPath=r}},yn.prototype.destroy=function(){this.element=null,this.globalData=null,this.maskElement=null,this.data=null,this.masksProperties=null};var bn=function(){var e={};e.createFilter=t,e.createAlphaToLuminanceFilter=n;function t(e,t){var n=z(`filter`);return n.setAttribute(`id`,e),t!==!0&&(n.setAttribute(`filterUnits`,`objectBoundingBox`),n.setAttribute(`x`,`0%`),n.setAttribute(`y`,`0%`),n.setAttribute(`width`,`100%`),n.setAttribute(`height`,`100%`)),n}function n(){var e=z(`feColorMatrix`);return e.setAttribute(`type`,`matrix`),e.setAttribute(`color-interpolation-filters`,`sRGB`),e.setAttribute(`values`,`0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 1`),e}return e}(),xn=function(){var e={maskType:!0,svgLumaHidden:!0,offscreenCanvas:typeof OffscreenCanvas<`u`};return(/MSIE 10/i.test(navigator.userAgent)||/MSIE 9/i.test(navigator.userAgent)||/rv:11.0/i.test(navigator.userAgent)||/Edge\/\d./i.test(navigator.userAgent))&&(e.maskType=!1),/firefox/i.test(navigator.userAgent)&&(e.svgLumaHidden=!1),e}(),Sn={},Cn=`filter_result_`;function wn(e){var t,n=`SourceGraphic`,r=e.data.ef?e.data.ef.length:0,i=I(),a=bn.createFilter(i,!0),o=0;this.filters=[];var s;for(t=0;t=0&&(n=this.shapeModifiers[e].processShapes(this._isFirstFrame),!n);--e);}},searchProcessedElement:function(e){for(var t=this.processedElements,n=0,r=t.length;n.01)return!1;n+=1}return!0},zn.prototype.checkCollapsable=function(){if(this.o.length/2!=this.c.length/4)return!1;if(this.data.k.k[0].s)for(var e=0,t=this.data.k.k.length;e0;)c=r.transformers[g].mProps._mdf||c,--h,--g;if(c)for(h=f-r.styles[u].lvl,g=r.transformers.length-1;h>0;)m.multiply(r.transformers[g].mProps.v),--h,--g}else m=e;if(p=r.sh.paths,o=p._length,c){for(s=``,a=0;a=1?v=.99:v<=-1&&(v=-.99);var y=g*v,b=Math.cos(_+t.a.v)*y+a[0],x=Math.sin(_+t.a.v)*y+a[1];r.setAttribute(`fx`,b),r.setAttribute(`fy`,x),i&&!t.g._collapsable&&(t.of.setAttribute(`fx`,b),t.of.setAttribute(`fy`,x))}}}function u(e,t,n){var r=t.style,i=t.d;i&&(i._mdf||n)&&i.dashStr&&(r.pElem.setAttribute(`stroke-dasharray`,i.dashStr),r.pElem.setAttribute(`stroke-dashoffset`,i.dashoffset[0])),t.c&&(t.c._mdf||n)&&r.pElem.setAttribute(`stroke`,`rgb(`+C(t.c.v[0])+`,`+C(t.c.v[1])+`,`+C(t.c.v[2])+`)`),(t.o._mdf||n)&&r.pElem.setAttribute(`stroke-opacity`,t.o.v),(t.w._mdf||n)&&(r.pElem.setAttribute(`stroke-width`,t.w.v),r.msElem&&r.msElem.setAttribute(`stroke-width`,t.w.v))}return n}();function X(e,t,n){this.shapes=[],this.shapesData=e.shapes,this.stylesList=[],this.shapeModifiers=[],this.itemsData=[],this.processedElements=[],this.animatedContents=[],this.initElement(e,t,n),this.prevViewData=[]}u([fn,vn,Tn,An,En,pn,Dn],X),X.prototype.initSecondaryElement=function(){},X.prototype.identityMatrix=new K,X.prototype.buildExpressionInterface=function(){},X.prototype.createContent=function(){this.searchShapes(this.shapesData,this.itemsData,this.prevViewData,this.layerElement,0,[],!0),this.filterUniqueShapes()},X.prototype.filterUniqueShapes=function(){var e,t=this.shapes.length,n,r,i=this.stylesList.length,a,o=[],s=!1;for(r=0;r1&&s&&this.setShapesAsAnimated(o)}},X.prototype.setShapesAsAnimated=function(e){var t,n=e.length;for(t=0;t=0;--c){if(g=this.searchProcessedElement(e[c]),g?t[c]=n[g-1]:e[c]._render=o,e[c].ty===`fl`||e[c].ty===`st`||e[c].ty===`gf`||e[c].ty===`gs`||e[c].ty===`no`)g?t[c].style.closed=e[c].hd:t[c]=this.createStyleElement(e[c],i),e[c]._render&&t[c].style.pElem.parentNode!==r&&r.appendChild(t[c].style.pElem),f.push(t[c].style);else if(e[c].ty===`gr`){if(!g)t[c]=this.createGroupElement(e[c]);else for(d=t[c].it.length,u=0;u1,this.kf&&this.addEffect(this.getKeyframeValue.bind(this)),this.kf},qn.prototype.addEffect=function(e){this.effectsSequence.push(e),this.elem.addDynamicProperty(this)},qn.prototype.getValue=function(e){if(!((this.elem.globalData.frameId===this.frameId||!this.effectsSequence.length)&&!e)){this.currentData.t=this.data.d.k[this.keysIndex].s.t;var t=this.currentData,n=this.keysIndex;if(this.lock){this.setCurrentData(this.currentData);return}this.lock=!0,this._mdf=!1;var r,i=this.effectsSequence.length,a=e||this.data.d.k[this.keysIndex].s;for(r=0;rt);)n+=1;return this.keysIndex!==n&&(this.keysIndex=n),this.data.d.k[this.keysIndex].s},qn.prototype.buildFinalText=function(e){for(var t=[],n=0,r=e.length,i,a,o=!1,s=!1,c=``;n=55296&&i<=56319?Xt.isRegionalFlag(e,n)?c=e.substr(n,14):(a=e.charCodeAt(n+1),a>=56320&&a<=57343&&(Xt.isModifier(i,a)?(c=e.substr(n,2),o=!0):c=Xt.isFlagEmoji(e.substr(n,4))?e.substr(n,4):e.substr(n,2))):i>56319?(a=e.charCodeAt(n+1),Xt.isVariationSelector(i)&&(o=!0)):Xt.isZeroWidthJoiner(i)&&(o=!0,s=!0),o?(t[t.length-1]+=c,o=!1):t.push(c),n+=c.length;return t},qn.prototype.completeTextData=function(e){e.__complete=!0;var t=this.elem.globalData.fontManager,n=this.data,r=[],i,a,o,s=0,c,l=n.m.g,u=0,d=0,f=0,p=[],m=0,h=0,g,_,v=t.getFontByName(e.f),y,b=0,x=Yt(v);e.fWeight=x.weight,e.fStyle=x.style,e.finalSize=e.s,e.finalText=this.buildFinalText(e.t),a=e.finalText.length,e.finalLineHeight=e.lh;var S=e.tr/1e3*e.finalSize,C;if(e.sz)for(var w=!0,T=e.sz[0],E=e.sz[1],D,O;w;){O=this.buildFinalText(e.t),D=0,m=0,a=O.length,S=e.tr/1e3*e.finalSize;var k=-1;for(i=0;iT&&O[i]!==` `?(k===-1?a+=1:i=k,D+=e.finalLineHeight||e.finalSize*1.2,O.splice(i,+(k===i),`\r`),k=-1,m=0):(m+=b,m+=S);D+=v.ascent*e.finalSize/100,this.canResize&&e.finalSize>this.minimumFontSize&&Eh?m:h,m=-2*S,c=``,o=!0,f+=1):c=j,t.chars?(y=t.getCharData(j,v.fStyle,t.getFontByName(e.f).fFamily),b=o?0:y.w*e.finalSize/100):b=t.measureText(c,e.f,e.finalSize),j===` `?A+=b+S:(m+=b+S+A,A=0),r.push({l:b,an:b,add:u,n:o,anIndexes:[],val:c,line:f,animatorJustifyOffset:0}),l==2){if(u+=b,c===``||c===` `||i===a-1){for((c===``||c===` `)&&(u-=b);d<=i;)r[d].an=u,r[d].ind=s,r[d].extra=b,d+=1;s+=1,u=0}}else if(l==3){if(u+=b,c===``||i===a-1){for(c===``&&(u-=b);d<=i;)r[d].an=u,r[d].ind=s,r[d].extra=b,d+=1;u=0,s+=1}}else r[s].ind=s,r[s].extra=0,s+=1;if(e.l=r,h=m>h?m:h,p.push(m),e.sz)e.boxWidth=e.sz[0],e.justifyOffset=0;else switch(e.boxWidth=h,e.j){case 1:e.justifyOffset=-e.boxWidth;break;case 2:e.justifyOffset=-e.boxWidth/2;break;default:e.justifyOffset=0}e.lineWidths=p;var M=n.a,N,P;_=M.length;var F,I,L=[];for(g=0;g<_;g+=1){for(N=M[g],N.a.sc&&(e.strokeColorAnim=!0),N.a.sw&&(e.strokeWidthAnim=!0),(N.a.fc||N.a.fh||N.a.fs||N.a.fb)&&(e.fillColorAnim=!0),I=0,F=N.s.b,i=0;i0?i=this.ne.v/100:a=-this.ne.v/100,this.xe.v>0?o=1-this.xe.v/100:s=1+this.xe.v/100;var c=be.getBezierEasing(i,a,o,s).get,l=0,u=this.finalS,d=this.finalE,f=this.data.sh;if(f===2)l=d===u?+(r>=d):e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l=c(l);else if(f===3)l=d===u?r>=d?0:1:1-e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l=c(l);else if(f===4)d===u?l=0:(l=e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l<.5?l*=2:l=1-2*(l-.5)),l=c(l);else if(f===5){if(d===u)l=0;else{var p=d-u;r=t(e(0,r+.5-u),d-u);var m=-p/2+r,h=p/2;l=Math.sqrt(1-m*m/(h*h))}l=c(l)}else f===6?(d===u?l=0:(r=t(e(0,r+.5-u),d-u),l=(1+Math.cos(Math.PI+Math.PI*2*r/(d-u)))/2),l=c(l)):(r>=n(u)&&(l=r-u<0?e(0,t(t(d,1)-(u-r),1)):e(0,t(d-r,1))),l=c(l));if(this.sm.v!==100){var g=this.sm.v*.01;g===0&&(g=1e-8);var _=.5-g*.5;l<_?l=0:(l=(l-_)/g,l>1&&(l=1))}return l*this.a.v},getValue:function(e){this.iterateDynamicProperties(),this._mdf=e||this._mdf,this._currentTextLength=this.elem.textProperty.currentData.l.length||0,e&&this.data.r===2&&(this.e.v=this._currentTextLength);var t=this.data.r===2?1:100/this.data.totalChars,n=this.o.v/t,r=this.s.v/t+n,i=this.e.v/t+n;if(r>i){var a=r;r=i,i=a}this.finalS=r,this.finalE=i}},u([Ve],r);function i(e,t,n){return new r(e,t,n)}return{getTextSelectorProp:i}}();function Yn(e,t,n){var r={propType:!1},i=W.getProp,a=t.a;this.a={r:a.r?i(e,a.r,0,D,n):r,rx:a.rx?i(e,a.rx,0,D,n):r,ry:a.ry?i(e,a.ry,0,D,n):r,sk:a.sk?i(e,a.sk,0,D,n):r,sa:a.sa?i(e,a.sa,0,D,n):r,s:a.s?i(e,a.s,1,.01,n):r,a:a.a?i(e,a.a,1,0,n):r,o:a.o?i(e,a.o,0,.01,n):r,p:a.p?i(e,a.p,1,0,n):r,sw:a.sw?i(e,a.sw,0,0,n):r,sc:a.sc?i(e,a.sc,1,0,n):r,fc:a.fc?i(e,a.fc,1,0,n):r,fh:a.fh?i(e,a.fh,0,0,n):r,fs:a.fs?i(e,a.fs,0,.01,n):r,fb:a.fb?i(e,a.fb,0,.01,n):r,t:a.t?i(e,a.t,0,0,n):r},this.s=Jn.getTextSelectorProp(e,t.s,n),this.s.t=t.s.t}function Xn(e,t,n){this._isFirstFrame=!0,this._hasMaskedPath=!1,this._frameId=-1,this._textData=e,this._renderType=t,this._elem=n,this._animatorsData=m(this._textData.a.length),this._pathData={},this._moreOptions={alignment:{}},this.renderedLetters=[],this.lettersChangedFlag=!1,this.initDynamicPropertyContainer(n)}Xn.prototype.searchProperties=function(){var e,t=this._textData.a.length,n,r=W.getProp;for(e=0;e=m+xe||!x?(T=(m+xe-g)/h.partialLength,ie=b.point[0]+(h.point[0]-b.point[0])*T,ae=b.point[1]+(h.point[1]-b.point[1])*T,a.translate(-n[0]*f[u].an*.005,-(n[1]*A)*.01),_=!1):x&&(g+=h.partialLength,v+=1,v>=x.length&&(v=0,y+=1,S[y]?x=S[y].points:D.v.c?(v=0,y=0,x=S[y].points):(g-=h.partialLength,x=null)),x&&(b=h,h=x[v],C=h.partialLength));re=f[u].an/2-f[u].add,a.translate(-re,0,0)}else re=f[u].an/2-f[u].add,a.translate(-re,0,0),a.translate(-n[0]*f[u].an*.005,-n[1]*A*.01,0);for(P=0;Pe?this.textSpans[e].span:z(s?`g`:`text`),b<=e){if(c.setAttribute(`stroke-linecap`,`butt`),c.setAttribute(`stroke-linejoin`,`round`),c.setAttribute(`stroke-miterlimit`,`4`),this.textSpans[e].span=c,s){var S=z(`g`);c.appendChild(S),this.textSpans[e].childSpan=S}this.textSpans[e].span=c,this.layerElement.appendChild(c)}c.style.display=`inherit`}if(l.reset(),d&&(o[e].n&&(f=-g,p+=n.yOffset,p+=+!!h,h=!1),this.applyTextPropertiesToMatrix(n,l,o[e].line,f,p),f+=o[e].l||0,f+=g),s){x=this.globalData.fontManager.getCharData(n.finalText[e],r.fStyle,this.globalData.fontManager.getFontByName(n.f).fFamily);var C;if(x.t===1)C=new ir(x.data,this.globalData,this);else{var w=Qn;x.data&&x.data.shapes&&(w=this.buildShapeData(x.data,n.finalSize)),C=new X(w,this.globalData,this)}if(this.textSpans[e].glyph){var T=this.textSpans[e].glyph;this.textSpans[e].childSpan.removeChild(T.layerElement),T.destroy()}this.textSpans[e].glyph=C,C._debug=!0,C.prepareFrame(0),C.renderFrame(),this.textSpans[e].childSpan.appendChild(C.layerElement),x.t===1&&this.textSpans[e].childSpan.setAttribute(`transform`,`scale(`+n.finalSize/100+`,`+n.finalSize/100+`)`)}else d&&c.setAttribute(`transform`,`translate(`+l.props[12]+`,`+l.props[13]+`)`),c.textContent=o[e].val,c.setAttributeNS(`http://www.w3.org/XML/1998/namespace`,`xml:space`,`preserve`)}d&&c&&c.setAttribute(`d`,u)}for(;e=0;--t)(this.completeLayers||this.elements[t])&&this.elements[t].prepareFrame(e-this.layers[t].st);if(this.globalData._mdf)for(t=0;t=0;--n)(this.completeLayers||this.elements[n])&&(this.elements[n].prepareFrame(this.renderedFrame-this.layers[n].st),this.elements[n]._mdf&&(this._mdf=!0))}},rr.prototype.renderInnerContent=function(){var e,t=this.layers.length;for(e=0;e=0;--n)e.finalTransform.multiply(e.transforms[n].transform.mProps.v);e._mdf=i},processSequences:function(e){var t,n=this.sequenceList.length;for(t=0;t=1){this.buffers=[];var e=this.globalData.canvasContext,t=lr.createCanvas(e.canvas.width,e.canvas.height);this.buffers.push(t);var n=lr.createCanvas(e.canvas.width,e.canvas.height);this.buffers.push(n),this.data.tt>=3&&!document._isProxy&&lr.loadLumaCanvas()}this.canvasContext=this.globalData.canvasContext,this.transformCanvas=this.globalData.transformCanvas,this.renderableEffectsManager=new dr(this),this.searchEffectTransforms()},createContent:function(){},setBlendMode:function(){var e=this.globalData;if(e.blendMode!==this.data.bm){e.blendMode=this.data.bm;var t=en(this.data.bm);e.canvasContext.globalCompositeOperation=t}},createRenderableComponents:function(){this.maskManager=new fr(this.data,this),this.transformEffects=this.renderableEffectsManager.getEffects(_n.TRANSFORM_EFFECT)},hideElement:function(){!this.hidden&&(!this.isInRange||this.isTransparent)&&(this.hidden=!0)},showElement:function(){this.isInRange&&!this.isTransparent&&(this.hidden=!1,this._isFirstFrame=!0,this.maskManager._isFirstFrame=!0)},clearCanvas:function(e){e.clearRect(this.transformCanvas.tx,this.transformCanvas.ty,this.transformCanvas.w*this.transformCanvas.sx,this.transformCanvas.h*this.transformCanvas.sy)},prepareLayer:function(){if(this.data.tt>=1){var e=this.buffers[0].getContext(`2d`);this.clearCanvas(e),e.drawImage(this.canvasContext.canvas,0,0),this.currentTransform=this.canvasContext.getTransform(),this.canvasContext.setTransform(1,0,0,1,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.setTransform(this.currentTransform)}},exitLayer:function(){if(this.data.tt>=1){var e=this.buffers[1],t=e.getContext(`2d`);if(this.clearCanvas(t),t.drawImage(this.canvasContext.canvas,0,0),this.canvasContext.setTransform(1,0,0,1,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.setTransform(this.currentTransform),this.comp.getElementById(`tp`in this.data?this.data.tp:this.data.ind-1).renderFrame(!0),this.canvasContext.setTransform(1,0,0,1,0,0),this.data.tt>=3&&!document._isProxy){var n=lr.getLumaCanvas(this.canvasContext.canvas);n.getContext(`2d`).drawImage(this.canvasContext.canvas,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.drawImage(n,0,0)}this.canvasContext.globalCompositeOperation=mr[this.data.tt],this.canvasContext.drawImage(e,0,0),this.canvasContext.globalCompositeOperation=`destination-over`,this.canvasContext.drawImage(this.buffers[0],0,0),this.canvasContext.setTransform(this.currentTransform),this.canvasContext.globalCompositeOperation=`source-over`}},renderFrame:function(e){if(!(this.hidden||this.data.hd)&&!(this.data.td===1&&!e)){this.renderTransform(),this.renderRenderable(),this.renderLocalTransform(),this.setBlendMode();var t=this.data.ty===0;this.prepareLayer(),this.globalData.renderer.save(t),this.globalData.renderer.ctxTransform(this.finalTransform.localMat.props),this.globalData.renderer.ctxOpacity(this.finalTransform.localOpacity),this.renderInnerContent(),this.globalData.renderer.restore(t),this.exitLayer(),this.maskManager.hasMasks&&this.globalData.renderer.restore(!0),this._isFirstFrame&&=!1}},destroy:function(){this.canvasContext=null,this.data=null,this.globalData=null,this.maskManager.destroy()},mHelper:new K},pr.prototype.hide=pr.prototype.hideElement,pr.prototype.show=pr.prototype.showElement;function hr(e,t,n,r){this.styledShapes=[],this.tr=[0,0,0,0,0,0];var i=4;t.ty===`rc`?i=5:t.ty===`el`?i=6:t.ty===`sr`&&(i=7),this.sh=G.getShapeProp(e,t,i,e);var a,o=n.length,s;for(a=0;a=0;--a){if(d=this.searchProcessedElement(e[a]),d?t[a]=n[d-1]:e[a]._shouldRender=r,e[a].ty===`fl`||e[a].ty===`st`||e[a].ty===`gf`||e[a].ty===`gs`)d?t[a].style.closed=!1:t[a]=this.createStyleElement(e[a],m),l.push(t[a].style);else if(e[a].ty===`gr`){if(!d)t[a]=this.createGroupElement(e[a]);else for(c=t[a].it.length,s=0;s=0;--i)t[i].ty===`tr`?(o=n[i].transform,this.renderShapeTransform(e,o)):t[i].ty===`sh`||t[i].ty===`el`||t[i].ty===`rc`||t[i].ty===`sr`?this.renderPath(t[i],n[i]):t[i].ty===`fl`?this.renderFill(t[i],n[i],o):t[i].ty===`st`?this.renderStroke(t[i],n[i],o):t[i].ty===`gf`||t[i].ty===`gs`?this.renderGradientFill(t[i],n[i],o):t[i].ty===`gr`?this.renderShape(o,t[i].it,n[i].it):t[i].ty;r&&this.drawLayer()},gr.prototype.renderStyledShape=function(e,t){if(this._isFirstFrame||t._mdf||e.transforms._mdf){var n=e.trNodes,r=t.paths,i,a,o,s=r._length;n.length=0;var c=e.transforms.finalTransform;for(o=0;o=1?u=.99:u<=-1&&(u=-.99);var d=c*u,f=Math.cos(l+t.a.v)*d+o[0],p=Math.sin(l+t.a.v)*d+o[1];i=a.createRadialGradient(f,p,0,o[0],o[1],c)}var m,h=e.g.p,g=t.g.c,_=1;for(m=0;ma&&c===`xMidYMid slice`||ii&&s===`meet`||ai&&s===`slice`)?this.transformCanvas.tx=(n-this.transformCanvas.w*(r/this.transformCanvas.h))/2*this.renderConfig.dpr:l===`xMax`&&(ai&&s===`slice`)?this.transformCanvas.tx=(n-this.transformCanvas.w*(r/this.transformCanvas.h))*this.renderConfig.dpr:this.transformCanvas.tx=0,u===`YMid`&&(a>i&&s===`meet`||ai&&s===`meet`||a=0;--e)this.elements[e]&&this.elements[e].destroy&&this.elements[e].destroy();this.elements.length=0,this.globalData.canvasContext=null,this.animationItem.container=null,this.destroyed=!0},Z.prototype.renderFrame=function(e,t){if(!(this.renderedFrame===e&&this.renderConfig.clearCanvas===!0&&!t||this.destroyed||e===-1)){this.renderedFrame=e,this.globalData.frameNum=e-this.animationItem._isFirstFrame,this.globalData.frameId+=1,this.globalData._mdf=!this.renderConfig.clearCanvas||t,this.globalData.projectInterface.currentFrame=e;var n,r=this.layers.length;for(this.completeLayers||this.checkLayers(e),n=r-1;n>=0;--n)(this.completeLayers||this.elements[n])&&this.elements[n].prepareFrame(e-this.layers[n].st);if(this.globalData._mdf){for(this.renderConfig.clearCanvas===!0?this.canvasContext.clearRect(0,0,this.transformCanvas.w,this.transformCanvas.h):this.save(),n=r-1;n>=0;--n)(this.completeLayers||this.elements[n])&&this.elements[n].renderFrame();this.renderConfig.clearCanvas!==!0&&this.restore()}}},Z.prototype.buildItem=function(e){var t=this.elements;if(!(t[e]||this.layers[e].ty===99)){var n=this.createItem(this.layers[e],this,this.globalData);t[e]=n,n.initExpressions()}},Z.prototype.checkPendingElements=function(){for(;this.pendingElements.length;)this.pendingElements.pop().checkParenting()},Z.prototype.hide=function(){this.animationItem.container.style.display=`none`},Z.prototype.show=function(){this.animationItem.container.style.display=`block`};function br(){this.opacity=-1,this.transform=p(`float32`,16),this.fillStyle=``,this.strokeStyle=``,this.lineWidth=``,this.lineCap=``,this.lineJoin=``,this.miterLimit=``,this.id=Math.random()}function xr(){this.stack=[],this.cArrPos=0,this.cTr=new K;var e,t=15;for(e=0;e=0;--t)(this.completeLayers||this.elements[t])&&this.elements[t].renderFrame()},Sr.prototype.destroy=function(){var e;for(e=this.layers.length-1;e>=0;--e)this.elements[e]&&this.elements[e].destroy();this.layers=null,this.elements=null},Sr.prototype.createComp=function(e){return new Sr(e,this.globalData,this)};function Cr(e,t){this.animationItem=e,this.renderConfig={clearCanvas:t&&t.clearCanvas!==void 0?t.clearCanvas:!0,context:t&&t.context||null,progressiveLoad:t&&t.progressiveLoad||!1,preserveAspectRatio:t&&t.preserveAspectRatio||`xMidYMid meet`,imagePreserveAspectRatio:t&&t.imagePreserveAspectRatio||`xMidYMid slice`,contentVisibility:t&&t.contentVisibility||`visible`,className:t&&t.className||``,id:t&&t.id||``,runExpressions:!t||t.runExpressions===void 0||t.runExpressions},this.renderConfig.dpr=t&&t.dpr||1,this.animationItem.wrapper&&(this.renderConfig.dpr=t&&t.dpr||window.devicePixelRatio||1),this.renderedFrame=-1,this.globalData={frameNum:-1,_mdf:!1,renderConfig:this.renderConfig,currentGlobalAlpha:-1},this.contextData=new xr,this.elements=[],this.pendingElements=[],this.transformMat=new K,this.completeLayers=!1,this.rendererType=`canvas`,this.renderConfig.clearCanvas&&(this.ctxTransform=this.contextData.transform.bind(this.contextData),this.ctxOpacity=this.contextData.opacity.bind(this.contextData),this.ctxFillStyle=this.contextData.fillStyle.bind(this.contextData),this.ctxStrokeStyle=this.contextData.strokeStyle.bind(this.contextData),this.ctxLineWidth=this.contextData.lineWidth.bind(this.contextData),this.ctxLineCap=this.contextData.lineCap.bind(this.contextData),this.ctxLineJoin=this.contextData.lineJoin.bind(this.contextData),this.ctxMiterLimit=this.contextData.miterLimit.bind(this.contextData),this.ctxFill=this.contextData.fill.bind(this.contextData),this.ctxFillRect=this.contextData.fillRect.bind(this.contextData),this.ctxStroke=this.contextData.stroke.bind(this.contextData),this.save=this.contextData.save.bind(this.contextData))}return u([Z],Cr),Cr.prototype.createComp=function(e){return new Sr(e,this.globalData,this)},ge(`canvas`,Cr),dt.registerModifier(`tm`,pt),dt.registerModifier(`pb`,mt),dt.registerModifier(`rp`,gt),dt.registerModifier(`rd`,_t),dt.registerModifier(`zz`,Ft),dt.registerModifier(`op`,Jt),J}))}))(),1),_t=0,vt=e=>`${e}-${++_t}`,yt=e=>({key:vt(e),name:``,rarity:`1000`,sortOrder:`0`,file:null,animation:null,fileError:``}),bt=()=>({key:vt(`backdrop`),name:``,backdropID:`1`,rarity:`1000`,sortOrder:`0`,center:`#6f5bea`,edge:`#34278f`,pattern:`#a89df5`,text:`#ffffff`});function xt({data:e,compact:t=!1}){let n=(0,g.useRef)(null);return(0,g.useEffect)(()=>{if(!n.current)return;let t=gt.default.loadAnimation({container:n.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)});return()=>t.destroy()},[e]),(0,H.jsx)(`div`,{className:`collectible-animation ${t?`compact`:``}`,ref:n})}function St({giftID:e,attribute:t}){let[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(!1);return(0,g.useEffect)(()=>{let n=!1;return a(!1),x.giftCollectibleAnimation(e,t.kind,t.id).then(e=>{n||r(e)}).catch(()=>{n||a(!0)}),()=>{n=!0}},[e,t.id,t.kind]),i?(0,H.jsx)(`div`,{className:`collectible-animation compact failed`,children:`!`}):n?(0,H.jsx)(xt,{data:n,compact:!0}):(0,H.jsx)(`div`,{className:`collectible-animation compact loading`,children:(0,H.jsx)(A,{className:`spin`,size:15})})}async function Ct(e){let t=new Uint8Array(await e.arrayBuffer()),n=t;if(t.length>=2&&t[0]===31&&t[1]===139){if(!(`DecompressionStream`in window))throw Error(`This browser cannot preview TGS files`);let e=new Blob([t]).stream().pipeThrough(new DecompressionStream(`gzip`));n=new Uint8Array(await new Response(e).arrayBuffer())}let r=JSON.parse(new TextDecoder().decode(n));if(!r||typeof r!=`object`||Array.isArray(r))throw Error(`Invalid Lottie JSON`);return r}var wt=e=>Number.parseInt(e.replace(`#`,``),16),Tt=e=>e.rarity_kind===`permille`?`${e.rarity_permille}‰`:e.rarity_kind;function Et({gift:e,onClose:t,onPublished:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(!0),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``),[f,p]=(0,g.useState)(null),[m,h]=(0,g.useState)(`100`),[_,v]=(0,g.useState)(`1000`),[y,S]=(0,g.useState)(`gift-${e.GiftID}`),[C,w]=(0,g.useState)(``),[T,E]=(0,g.useState)([yt(`model`)]),[D,O]=(0,g.useState)([yt(`pattern`)]),[M,N]=(0,g.useState)([bt()]);(0,g.useEffect)(()=>{let t=!1;return x.giftCollectibles(e.GiftID).then(n=>{t||(a(n),n.found&&(h(String(n.upgrade_stars??100)),v(String(n.supply_total??1e3)),S(n.slug_prefix??`gift-${e.GiftID}`)))}).catch(e=>d(b(e))).finally(()=>{t||s(!1)}),()=>{t=!0}},[e.GiftID]);let P=(0,g.useMemo)(()=>({models:T.reduce((e,t)=>e+Number(t.rarity||0),0),patterns:D.reduce((e,t)=>e+Number(t.rarity||0),0),backdrops:M.reduce((e,t)=>e+Number(t.rarity||0),0)}),[T,D,M]),F=()=>p(null),I=(e,t,n)=>{(e===`models`?E:O)(e=>e.map(e=>e.key===t?{...e,...n}:e)),F()};async function L(e,t,n){if(I(e,t.key,{file:n,animation:null,fileError:``}),n)try{let r=await Ct(n);I(e,t.key,{animation:r,fileError:``})}catch(n){I(e,t.key,{animation:null,fileError:b(n)})}}function ee(e,t=``){if(!C.trim())throw Error(r(`action.reasonRequired`));for(let e of[...T,...D])if(!e.file)throw Error(r(`collectibles.fileRequired`));let n=new FormData,i=e=>e.map(e=>({name:e.name.trim(),rarity_permille:Number(e.rarity),sort_order:Number(e.sortOrder),file_key:e.key}));n.set(`metadata`,JSON.stringify({command_id:t,reason:C.trim(),confirm:e,upgrade_stars:m,supply_total:Number(_),slug_prefix:y.trim().toLowerCase(),models:i(T),patterns:i(D),backdrops:M.map(e=>({name:e.name.trim(),backdrop_id:Number(e.backdropID),rarity_permille:Number(e.rarity),sort_order:Number(e.sortOrder),center_color:wt(e.center),edge_color:wt(e.edge),pattern_color:wt(e.pattern),text_color:wt(e.text)}))}));for(let e of[...T,...D])n.set(e.key,e.file,e.file.name);return n}async function te(){l(!0),d(``),p(null);try{p(await x.publishGiftCollectibles(e.GiftID,ee(!1)))}catch(e){d(b(e))}finally{l(!1)}}async function re(){if(f){l(!0),d(``);try{await x.publishGiftCollectibles(e.GiftID,ee(!0,f.command_id)),n(),t()}catch(e){d(b(e))}finally{l(!1)}}}let ie=(e,t,n)=>(0,H.jsxs)(`section`,{className:`collectible-section`,children:[(0,H.jsxs)(`div`,{className:`collectible-section-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.${e}`)}),(0,H.jsx)(`span`,{children:r(`collectibles.rarityHint`)})]}),(0,H.jsxs)(`div`,{className:`collectible-section-tools`,children:[(0,H.jsxs)(G,{tone:P[e]>0?`good`:`neutral`,children:[P[e],`‰`]}),(0,H.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>{n([...t,yt(e===`models`?`model`:`pattern`)]),F()},children:[(0,H.jsx)(z,{size:13}),r(`collectibles.addAttribute`)]})]})]}),(0,H.jsx)(`div`,{className:`collectible-rows`,children:t.map((i,a)=>(0,H.jsxs)(`div`,{className:`collectible-row animated`,children:[(0,H.jsx)(`div`,{className:`collectible-row-index`,children:a+1}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`common.name`)}),(0,H.jsx)(`input`,{value:i.name,maxLength:128,onChange:t=>I(e,i.key,{name:t.target.value})})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.rarity`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,max:`1000`,value:i.rarity,onChange:t=>I(e,i.key,{rarity:t.target.value})})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.sortOrder`)}),(0,H.jsx)(`input`,{type:`number`,value:i.sortOrder,onChange:t=>I(e,i.key,{sortOrder:t.target.value})})]}),(0,H.jsxs)(`label`,{className:`collectible-file`,children:[(0,H.jsx)(`span`,{children:r(`gifts.animation`)}),(0,H.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.lottie,application/json,application/x-tgsticker`,onChange:t=>void L(e,i,t.target.files?.[0]??null)}),(0,H.jsxs)(`em`,{children:[(0,H.jsx)(R,{size:13}),i.file?.name??r(`gifts.chooseFile`)]})]}),(0,H.jsx)(`div`,{className:`collectible-inline-preview`,children:i.animation?(0,H.jsx)(xt,{data:i.animation,compact:!0}):(0,H.jsx)(j,{size:16})}),(0,H.jsx)(`button`,{className:`icon-btn danger`,type:`button`,disabled:t.length===1,onClick:()=>{n(t.filter(e=>e.key!==i.key)),F()},"aria-label":r(`collectibles.remove`),children:(0,H.jsx)(he,{size:14})}),i.fileError&&(0,H.jsx)(`span`,{className:`collectible-file-error`,children:i.fileError})]},i.key))})]});return(0,Qe.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal collectible-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":r(`collectibles.title`,{id:e.GiftID}),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:r(`collectibles.eyebrow`)}),(0,H.jsx)(`h2`,{children:r(`collectibles.title`,{id:e.GiftID})}),(0,H.jsx)(`p`,{children:e.Title||`Gift #${e.GiftID}`})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:t,disabled:c,"aria-label":r(`action.close`),children:(0,H.jsx)(ve,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body collectible-modal-body`,children:[o?(0,H.jsxs)(`div`,{className:`collectible-loading`,children:[(0,H.jsx)(A,{className:`spin`}),r(`common.loading`)]}):i?.found?(0,H.jsxs)(`section`,{className:`collectible-active`,children:[(0,H.jsxs)(`div`,{className:`collectible-active-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(ne,{size:18}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.activeRevision`,{revision:i.revision??0})}),(0,H.jsxs)(`span`,{children:[i.slug_prefix,` · ⭐ `,i.upgrade_stars,` · `,i.issued,` / `,i.supply_total]})]})]}),(0,H.jsx)(G,{tone:`good`,children:r(`collectibles.published`)})]}),(0,H.jsxs)(`div`,{className:`collectible-active-grid`,children:[[...i.models??[],...i.patterns??[]].map(t=>(0,H.jsxs)(`article`,{children:[(0,H.jsx)(St,{giftID:e.GiftID,attribute:t}),(0,H.jsxs)(`div`,{children:[(0,H.jsxs)(`strong`,{children:[t.name,t.crafted&&(0,H.jsx)(G,{children:`crafted`})]}),(0,H.jsxs)(`span`,{children:[r(`collectibles.${t.kind}`),` · `,Tt(t)]})]})]},`${t.kind}-${t.id}`)),(i.backdrops??[]).map(e=>(0,H.jsxs)(`article`,{children:[(0,H.jsx)(`div`,{className:`collectible-backdrop-preview`,style:{background:`radial-gradient(circle, #${(e.center_color??0).toString(16).padStart(6,`0`)}, #${(e.edge_color??0).toString(16).padStart(6,`0`)})`,color:`#${(e.text_color??16777215).toString(16).padStart(6,`0`)}`},children:`Aa`}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:e.name}),(0,H.jsxs)(`span`,{children:[r(`collectibles.backdrop`),` · `,Tt(e)]})]})]},`backdrop-${e.id}`))]})]}):(0,H.jsxs)(`div`,{className:`collectible-empty`,children:[(0,H.jsx)(ne,{size:22}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.noPool`)}),(0,H.jsx)(`span`,{children:r(`collectibles.noPoolHint`)})]})]}),(0,H.jsxs)(`section`,{className:`collectible-definition`,children:[(0,H.jsxs)(`div`,{className:`collectible-definition-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.publishNew`)}),(0,H.jsx)(`span`,{children:r(`collectibles.immutableHint`)})]}),(0,H.jsxs)(`div`,{className:`gift-format-chips`,children:[(0,H.jsx)(`span`,{children:`TGS`}),(0,H.jsx)(`span`,{children:`Lottie JSON`})]})]}),(0,H.jsxs)(`div`,{className:`gift-fields-grid collectible-main-fields`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.upgradeStars`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:m,onChange:e=>{h(e.target.value),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.supply`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:_,onChange:e=>{v(e.target.value),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.slug`)}),(0,H.jsx)(`input`,{value:y,maxLength:48,onChange:e=>{S(e.target.value.toLowerCase()),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.reason`)}),(0,H.jsx)(`input`,{value:C,maxLength:1e3,placeholder:r(`gifts.reasonPlaceholder`),onChange:e=>w(e.target.value)})]})]}),ie(`models`,T,E),ie(`patterns`,D,O),(0,H.jsxs)(`section`,{className:`collectible-section`,children:[(0,H.jsxs)(`div`,{className:`collectible-section-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.backdrops`)}),(0,H.jsx)(`span`,{children:r(`collectibles.colorHint`)})]}),(0,H.jsxs)(`div`,{className:`collectible-section-tools`,children:[(0,H.jsxs)(G,{tone:P.backdrops>0?`good`:`neutral`,children:[P.backdrops,`‰`]}),(0,H.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>{N([...M,bt()]),F()},children:[(0,H.jsx)(z,{size:13}),r(`collectibles.addAttribute`)]})]})]}),(0,H.jsx)(`div`,{className:`collectible-rows`,children:M.map((e,t)=>(0,H.jsxs)(`div`,{className:`collectible-row backdrop`,children:[(0,H.jsx)(`div`,{className:`collectible-row-index`,children:t+1}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`common.name`)}),(0,H.jsx)(`input`,{value:e.name,maxLength:128,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,name:t.target.value}:n)),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.backdropID`)}),(0,H.jsx)(`input`,{type:`number`,min:`0`,value:e.backdropID,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,backdropID:t.target.value}:n)),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.rarity`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,max:`1000`,value:e.rarity,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,rarity:t.target.value}:n)),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.sortOrder`)}),(0,H.jsx)(`input`,{type:`number`,value:e.sortOrder,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,sortOrder:t.target.value}:n)),F()}})]}),[`center`,`edge`,`pattern`,`text`].map(t=>(0,H.jsxs)(`label`,{className:`collectible-color`,children:[(0,H.jsx)(`span`,{children:r(`collectibles.color.${t}`)}),(0,H.jsx)(`input`,{type:`color`,value:e[t],onChange:n=>{N(M.map(r=>r.key===e.key?{...r,[t]:n.target.value}:r)),F()}})]},t)),(0,H.jsx)(`div`,{className:`collectible-backdrop-preview`,style:{background:`radial-gradient(circle, ${e.center}, ${e.edge})`,color:e.text},children:`Aa`}),(0,H.jsx)(`button`,{className:`icon-btn danger`,type:`button`,disabled:M.length===1,onClick:()=>{N(M.filter(t=>t.key!==e.key)),F()},"aria-label":r(`collectibles.remove`),children:(0,H.jsx)(he,{size:14})})]},e.key))})]})]}),u&&(0,H.jsx)(Ke,{children:u}),f&&(0,H.jsxs)(`div`,{className:`gift-validation`,children:[(0,H.jsxs)(`div`,{className:`gift-validation-head`,children:[(0,H.jsx)(k,{size:17}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.validationReady`)}),(0,H.jsx)(`span`,{children:r(`collectibles.validationHint`)})]})]}),(0,H.jsx)(`pre`,{children:JSON.stringify(f.details,null,2)})]})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:t,disabled:c,children:r(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:te,disabled:c,children:[c?(0,H.jsx)(A,{className:`spin`,size:15}):(0,H.jsx)(fe,{size:15}),r(`gifts.validate`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:re,disabled:c||!f,children:[(0,H.jsx)(ge,{size:15}),r(`collectibles.publish`)]})]})]})}),document.body)}function Dt(e){return e.model_count+e.pattern_count+e.backdrop_count}function Ot(e){let t=Number(e);return t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(1)} KB`:`${(t/(1024*1024)).toFixed(1)} MB`}function kt({giftID:e,revision:t,compact:n=!1}){let r=(0,g.useRef)(null),i=(0,g.useRef)(null),[a,o]=(0,g.useState)(!0),[s,c]=(0,g.useState)(``);(0,g.useEffect)(()=>{let t=!1;return x.giftAnimation(e).then(e=>{t||!r.current||(i.current?.destroy(),i.current=gt.default.loadAnimation({container:r.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)}))}).catch(e=>c(b(e))),()=>{t=!0,i.current?.destroy(),i.current=null}},[e,t]);function l(){i.current&&(a?i.current.pause():i.current.play(),o(!a))}return(0,H.jsxs)(`div`,{className:`gift-animation-shell ${n?`compact`:``}`,children:[(0,H.jsx)(`div`,{className:`gift-animation`,ref:r,children:s&&(0,H.jsx)(`span`,{children:s})}),(0,H.jsx)(`button`,{className:`gift-play`,type:`button`,onClick:l,"aria-label":a?`Pause`:`Play`,children:a?(0,H.jsx)(le,{size:14}):(0,H.jsx)(ue,{size:14})})]})}function At({sourceGiftID:e}){let t=(0,g.useRef)(null);return(0,g.useEffect)(()=>{let n=!1,r=null;return x.officialGiftAnimation(e).then(e=>{n||!t.current||(r=gt.default.loadAnimation({container:t.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)}))}).catch(()=>void 0),()=>{n=!0,r?.destroy()}},[e]),(0,H.jsx)(`div`,{className:`gift-animation-shell`,children:(0,H.jsx)(`div`,{className:`gift-animation`,ref:t})})}function jt(){let{t:e}=Ce(),[t,n]=(0,g.useState)([]),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(!1),[s,c]=(0,g.useState)(null),[l,u]=(0,g.useState)(null),[d,f]=(0,g.useState)(`official`),[p,m]=(0,g.useState)([]),[h,_]=(0,g.useState)(``),[v,y]=(0,g.useState)(`all`),[S,C]=(0,g.useState)(``),[w,T]=(0,g.useState)(!0),[E,D]=(0,g.useState)(`0`),[O,j]=(0,g.useState)(`0`),[M,N]=(0,g.useState)(``),[P,F]=(0,g.useState)(`0`),[I,L]=(0,g.useState)(``),[ee,te]=(0,g.useState)(`50`),[re,ie]=(0,g.useState)(`50`),[ae,oe]=(0,g.useState)(`0`),[se,ce]=(0,g.useState)(!0),[le,ue]=(0,g.useState)(``),[V,pe]=(0,g.useState)(null),[me,he]=(0,g.useState)(!1),[_e,ye]=(0,g.useState)(``),[U,be]=(0,g.useState)(``);async function xe(){ye(``);try{n((await x.gifts()).Gifts??[])}catch(e){ye(b(e))}}(0,g.useEffect)(()=>{xe()},[]),(0,g.useEffect)(()=>{!a||d!==`official`||p.length>0||x.officialGifts().then(e=>m(e.gifts??[])).catch(e=>be(b(e)))},[a,d,p.length]);let Se=(0,g.useMemo)(()=>p.find(e=>e.source_gift_id===S)??null,[p,S]),we=(0,g.useMemo)(()=>({all:p.length,upgrade:p.filter(e=>e.can_upgrade).length,craft:p.filter(e=>e.can_craft).length,basic:p.filter(e=>!e.can_upgrade).length}),[p]),Te=(0,g.useMemo)(()=>{let e=h.trim().toLowerCase();return p.filter(t=>(v===`all`||v===`upgrade`&&t.can_upgrade||v===`craft`&&t.can_craft||v===`basic`&&!t.can_upgrade)&&(!e||t.source_gift_id.includes(e)||t.title.toLowerCase().includes(e)))},[p,h,v]),Ee=(0,g.useMemo)(()=>{let e=r.trim().toLowerCase();return e?t.filter(t=>String(t.GiftID).includes(e)||t.Title.toLowerCase().includes(e)||t.SourceFormat.toLowerCase().includes(e)):t},[t,r]);function De(t,n=``){if(!l)throw Error(e(`gifts.fileRequired`));if(!le.trim())throw Error(e(`action.reasonRequired`));let r=new FormData;return r.set(`metadata`,JSON.stringify({command_id:n,reason:le.trim(),confirm:t,gift_id:P,title:I.trim(),stars:ee,convert_stars:re,enabled:se,sort_order:Number(ae)})),r.set(`file`,l,l.name),r}function Oe(t,n=``){if(!S)throw Error(e(`gifts.officialRequired`));if(!le.trim())throw Error(e(`action.reasonRequired`));return{command_id:n,reason:le.trim(),confirm:t,source_gift_id:S,gift_id:P,title:I.trim(),stars:ee,convert_stars:re,enabled:se,sort_order:Number(ae),include_collectible:w,upgrade_stars:E,supply_total:Number(O),slug_prefix:M.trim().toLowerCase()}}function ke(t){C(t.source_gift_id),L(t.title||e(`gifts.officialUnnamed`,{id:t.source_gift_id})),te(String(t.stars)),ie(String(t.convert_stars)),T(t.can_upgrade),D(t.upgrade_stars),j(String(t.availability_total||1)),N(`official-${t.source_gift_id}`),pe(null)}async function Ae(){he(!0),be(``),pe(null);try{pe(d===`official`?await x.importOfficialGift(Oe(!1)):await x.importGift(De(!1)))}catch(e){be(b(e))}finally{he(!1)}}async function je(){if(V){he(!0),be(``);try{d===`official`?await x.importOfficialGift(Oe(!0,V.command_id)):await x.importGift(De(!0,V.command_id)),pe(null),u(null),F(`0`),L(``),C(``),await xe(),o(!1)}catch(e){be(b(e))}finally{he(!1)}}}function Me(){F(`0`),L(``),te(`50`),ie(`50`),oe(`0`),ce(!0),ue(``),u(null),pe(null),be(``),f(`official`),C(``),_(``),y(`all`),o(!0)}function Ne(e){F(e.GiftID),L(e.Title),te(String(e.Stars)),ie(String(e.ConvertStars)),oe(String(e.SortOrder)),ce(e.Enabled),ue(``),u(null),pe(null),be(``),f(`official`),C(``),_(``),y(`all`),o(!0)}return(0,H.jsxs)(He,{title:e(`gifts.pageTitle`),eyebrow:e(`gifts.eyebrow`),actions:(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>xe(),disabled:me,children:[(0,H.jsx)(de,{size:15}),` `,e(`common.refresh`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:Me,children:[(0,H.jsx)(z,{size:15}),` `,e(`gifts.add`)]})]}),children:[_e&&(0,H.jsx)(Ke,{children:_e}),(0,H.jsxs)(`div`,{className:`metric-row gift-metrics`,children:[(0,H.jsx)(q,{label:e(`gifts.total`),value:String(t.length)}),(0,H.jsx)(q,{label:e(`gifts.enabled`),value:String(t.filter(e=>e.Enabled).length),tone:`good`}),(0,H.jsx)(q,{label:e(`gifts.received`),value:t.reduce((e,t)=>e+BigInt(t.ReceivedCount),0n).toString()}),(0,H.jsx)(q,{label:e(`gifts.formats`),value:`TGS / Lottie`})]}),(0,H.jsx)(Ue,{children:(0,H.jsxs)(`div`,{className:`toolbar`,children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),placeholder:e(`gifts.searchPlaceholder`)})]}),(0,H.jsx)(`span`,{className:`gift-list-summary`,children:e(`gifts.listSummary`,{shown:Ee.length,total:t.length})})]})}),(0,H.jsx)(`div`,{className:`table-wrap gift-table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table gift-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:e(`gifts.animation`)}),(0,H.jsx)(`th`,{children:e(`gifts.idRevision`)}),(0,H.jsx)(`th`,{children:e(`gifts.title`)}),(0,H.jsx)(`th`,{children:e(`gifts.price`)}),(0,H.jsx)(`th`,{children:e(`gifts.source`)}),(0,H.jsx)(`th`,{children:e(`gifts.received`)}),(0,H.jsx)(`th`,{children:e(`common.status`)}),(0,H.jsx)(`th`,{children:e(`common.updatedAt`)}),(0,H.jsx)(`th`,{children:e(`common.actions`)})]})}),(0,H.jsxs)(`tbody`,{children:[Ee.map(t=>(0,H.jsxs)(`tr`,{className:t.Enabled?``:`gift-row-disabled`,children:[(0,H.jsx)(`td`,{children:(0,H.jsx)(kt,{giftID:t.GiftID,revision:t.Revision,compact:!0})}),(0,H.jsxs)(`td`,{className:`mono`,children:[t.GiftID,` / `,t.Revision]}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(`strong`,{className:`gift-table-title`,children:t.Title||`Gift #${t.GiftID}`}),(0,H.jsxs)(`span`,{className:`gift-sort-order`,children:[e(`gifts.sortOrder`),`: `,t.SortOrder]})]}),(0,H.jsxs)(`td`,{children:[(0,H.jsxs)(`strong`,{className:`gift-table-price`,children:[`⭐ `,t.Stars]}),(0,H.jsxs)(`span`,{className:`gift-convert-price`,children:[`→ `,t.ConvertStars]})]}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(G,{children:t.SourceFormat}),(0,H.jsx)(`span`,{className:`gift-source-size`,children:Ot(t.AnimationSize)})]}),(0,H.jsx)(`td`,{children:t.ReceivedCount}),(0,H.jsx)(`td`,{children:(0,H.jsx)(G,{tone:t.Enabled?`good`:`neutral`,children:t.Enabled?e(`common.enabled`):e(`common.disabled`)})}),(0,H.jsx)(`td`,{children:ze(t.UpdatedAt)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`div`,{className:`gift-table-actions`,children:[(0,H.jsxs)(`button`,{className:`btn compact-btn collectible-button`,type:`button`,onClick:()=>c(t),children:[(0,H.jsx)(ne,{size:13}),e(`collectibles.manage`)]}),(0,H.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>Ne(t),children:e(`gifts.replace`)}),(0,H.jsx)($e,{compact:!0,tone:`neutral`,label:t.Enabled?e(`gifts.disable`):e(`gifts.enable`),path:`/api/actions/set-gift-enabled`,payload:()=>({gift_id:t.GiftID,enabled:!t.Enabled}),onDone:()=>void xe()})]})})]},t.GiftID)),Ee.length===0&&(0,H.jsx)(Je,{colSpan:9})]})]})}),a&&(0,Qe.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal gift-import-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":P===`0`?e(`gifts.importTitle`):e(`gifts.newRevision`,{id:P}),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:e(`gifts.importEyebrow`)}),(0,H.jsx)(`h2`,{children:P===`0`?e(`gifts.importTitle`):e(`gifts.newRevision`,{id:P})})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>o(!1),disabled:me,"aria-label":e(`action.close`),children:(0,H.jsx)(ve,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body gift-import-modal-body`,children:[(0,H.jsxs)(`div`,{className:`command-steps`,children:[(0,H.jsxs)(`div`,{className:`command-step ${(d===`official`?S:l)?`done`:`active`}`,children:[(0,H.jsx)(`span`,{children:`1`}),(0,H.jsx)(`strong`,{children:e(`gifts.stepDetails`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${V?`done`:(d===`official`?S:l)?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`2`}),(0,H.jsx)(`strong`,{children:e(`gifts.stepValidate`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${V?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`3`}),(0,H.jsx)(`strong`,{children:e(`gifts.stepImport`)})]})]}),(0,H.jsxs)(`div`,{className:`gift-source-tabs`,children:[(0,H.jsx)(`button`,{className:`btn ${d===`official`?`primary`:``}`,type:`button`,onClick:()=>{f(`official`),pe(null)},children:e(`gifts.officialSource`)}),(0,H.jsx)(`button`,{className:`btn ${d===`file`?`primary`:``}`,type:`button`,onClick:()=>{f(`file`),pe(null)},children:e(`gifts.fileSource`)})]}),d===`official`?(0,H.jsxs)(`section`,{className:`official-gift-picker`,children:[(0,H.jsxs)(`div`,{className:`gift-import-note`,children:[(0,H.jsx)(`span`,{children:e(`gifts.officialHint`)}),(0,H.jsxs)(`div`,{className:`gift-format-chips`,children:[(0,H.jsx)(`span`,{children:p.length}),(0,H.jsx)(`span`,{children:`SHA-256`})]})]}),(0,H.jsxs)(`div`,{className:`official-gift-tools`,children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:h,onChange:e=>_(e.target.value),placeholder:e(`gifts.officialSearch`)})]}),(0,H.jsx)(`span`,{children:e(`gifts.officialResults`,{shown:Te.length,total:p.length})})]}),(0,H.jsx)(`div`,{className:`official-gift-categories`,role:`group`,"aria-label":e(`gifts.officialCategoryLabel`),children:[`all`,`upgrade`,`craft`,`basic`].map(t=>(0,H.jsxs)(`button`,{className:v===t?`active`:``,type:`button`,"aria-pressed":v===t,onClick:()=>y(t),children:[e(`gifts.officialCategory.${t}`),(0,H.jsx)(`span`,{children:we[t]})]},t))}),(0,H.jsxs)(`div`,{className:`official-gift-list`,role:`listbox`,"aria-label":e(`gifts.officialSelect`),children:[Te.map(t=>{let n=t.source_gift_id===S;return(0,H.jsxs)(`button`,{className:`official-gift-option ${n?`selected`:``}`,type:`button`,role:`option`,"aria-selected":n,onClick:()=>ke(t),children:[(0,H.jsxs)(`span`,{className:`official-gift-option-head`,children:[(0,H.jsx)(`strong`,{children:t.title||e(`gifts.officialUnnamed`,{id:t.source_gift_id})}),(0,H.jsxs)(`span`,{className:`mono`,children:[`#`,t.source_gift_id]})]}),(0,H.jsxs)(`span`,{className:`official-gift-option-meta`,children:[(0,H.jsxs)(`span`,{children:[`⭐ `,t.stars]}),(0,H.jsx)(`span`,{children:e(`gifts.officialAttributes`,{count:Dt(t)})})]}),(0,H.jsxs)(`span`,{className:`official-gift-capabilities`,children:[(0,H.jsx)(`span`,{className:t.can_upgrade?`yes`:`no`,children:t.can_upgrade?e(`gifts.canUpgrade`):e(`gifts.cannotUpgrade`)}),(0,H.jsx)(`span`,{className:t.can_craft?`craft`:`no`,children:t.can_craft?e(`gifts.canCraft`):e(`gifts.cannotCraft`)})]})]},t.source_gift_id)}),Te.length===0&&(0,H.jsx)(`div`,{className:`official-gift-empty`,children:e(`gifts.officialEmpty`)})]}),Se&&(0,H.jsxs)(`div`,{className:`official-gift-selected`,children:[(0,H.jsx)(At,{sourceGiftID:Se.source_gift_id}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:Se.title||e(`gifts.officialUnnamed`,{id:Se.source_gift_id})}),(0,H.jsx)(`span`,{className:`mono`,children:Se.source_gift_id}),(0,H.jsxs)(`small`,{children:[Se.model_count,` `,e(`collectibles.models`),` · `,Se.pattern_count,` `,e(`collectibles.patterns`),` · `,Se.backdrop_count,` `,e(`collectibles.backdrops`)]}),(0,H.jsxs)(`span`,{className:`official-gift-capabilities`,children:[(0,H.jsx)(`span`,{className:Se.can_upgrade?`yes`:`no`,children:Se.can_upgrade?e(`gifts.canUpgrade`):e(`gifts.cannotUpgrade`)}),(0,H.jsx)(`span`,{className:Se.can_craft?`craft`:`no`,children:Se.can_craft?e(`gifts.canCraft`):e(`gifts.cannotCraft`)})]})]})]}),Se?.can_upgrade&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`label`,{className:`gift-switch`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:w,onChange:e=>{T(e.target.checked),pe(null)}}),(0,H.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,H.jsx)(`span`,{})}),(0,H.jsx)(`span`,{children:e(`gifts.includeCollectible`)})]}),w&&(0,H.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`collectibles.upgradeStars`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:E,onChange:e=>{D(e.target.value),pe(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`collectibles.supply`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:O,onChange:e=>{j(e.target.value),pe(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`collectibles.slug`)}),(0,H.jsx)(`input`,{value:M,maxLength:48,onChange:e=>{N(e.target.value.toLowerCase()),pe(null)}})]})]})]})]}):(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`div`,{className:`gift-import-note`,children:[(0,H.jsx)(`span`,{children:e(`gifts.importHint`)}),(0,H.jsxs)(`div`,{className:`gift-format-chips`,"aria-label":e(`gifts.formats`),children:[(0,H.jsx)(`span`,{children:`TGS`}),(0,H.jsx)(`span`,{children:`Lottie JSON`})]})]}),(0,H.jsxs)(`label`,{className:`gift-file-picker ${l?`has-file`:``}`,children:[(0,H.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.lottie,application/json,application/x-tgsticker`,onChange:e=>{u(e.target.files?.[0]??null),pe(null)}}),(0,H.jsx)(`span`,{className:`gift-file-icon`,children:(0,H.jsx)(R,{size:22})}),(0,H.jsxs)(`span`,{className:`gift-file-copy`,children:[(0,H.jsx)(`span`,{className:`gift-field-label`,children:e(`gifts.animation`)}),(0,H.jsx)(`strong`,{children:l?l.name:e(`gifts.filePrompt`)}),(0,H.jsx)(`small`,{children:l?Ot(l.size):e(`gifts.fileHint`)})]}),(0,H.jsx)(`span`,{className:`gift-file-action`,children:e(l?`gifts.changeFile`:`gifts.chooseFile`)})]})]}),(0,H.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.title`)}),(0,H.jsx)(`input`,{value:I,maxLength:128,placeholder:e(`gifts.titlePlaceholder`),onChange:e=>{L(e.target.value),pe(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.stars`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:ee,onChange:e=>{te(e.target.value),pe(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.convertStars`)}),(0,H.jsx)(`input`,{type:`number`,min:`0`,value:re,onChange:e=>{ie(e.target.value),pe(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.sortOrder`)}),(0,H.jsx)(`input`,{type:`number`,value:ae,onChange:e=>{oe(e.target.value),pe(null)}})]})]}),(0,H.jsxs)(`label`,{className:`gift-reason-field`,children:[(0,H.jsx)(`span`,{children:e(`gifts.reason`)}),(0,H.jsx)(`input`,{value:le,placeholder:e(`gifts.reasonPlaceholder`),onChange:e=>ue(e.target.value)})]}),(0,H.jsxs)(`label`,{className:`gift-switch`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:se,onChange:e=>{ce(e.target.checked),pe(null)}}),(0,H.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,H.jsx)(`span`,{})}),(0,H.jsx)(`span`,{children:e(`gifts.enableAfterImport`)})]}),U&&(0,H.jsx)(Ke,{children:U}),V&&(0,H.jsxs)(`div`,{className:`gift-validation`,children:[(0,H.jsxs)(`div`,{className:`gift-validation-head`,children:[(0,H.jsx)(k,{size:17}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:e(`gifts.validationReady`)}),(0,H.jsx)(`span`,{children:e(`gifts.validationHint`)})]})]}),(0,H.jsx)(`pre`,{children:JSON.stringify(V.details,null,2)})]})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>o(!1),disabled:me,children:e(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:Ae,disabled:me,children:[me?(0,H.jsx)(A,{className:`spin`,size:15}):(0,H.jsx)(fe,{size:15}),e(`gifts.validate`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:je,disabled:me||!V,children:[(0,H.jsx)(ge,{size:15}),e(`gifts.confirmImport`)]})]})]})}),document.body),s&&(0,H.jsx)(Et,{gift:s,onClose:()=>c(null),onPublished:()=>void xe()})]})}function Mt({route:e,navigate:t}){let n=e.path.match(/^\/accounts\/(\d+)$/)?.[1],r=e.path.match(/^\/channels\/(\d+)$/)?.[1];return n?(0,H.jsx)(tt,{id:Number(n),navigate:t}):r?(0,H.jsx)(ot,{id:Number(r),navigate:t}):e.path===`/accounts`?(0,H.jsx)(at,{navigate:t}):e.path===`/channels`?(0,H.jsx)(st,{navigate:t}):e.path===`/gifts`?(0,H.jsx)(jt,{}):e.path===`/messages/detail`||e.path===`/messages/private/detail`?(0,H.jsx)(mt,{ownerUserID:Number(e.search.get(`owner_user_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups/detail`?(0,H.jsx)(ut,{channelID:Number(e.search.get(`channel_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups`?(0,H.jsx)(pt,{navigate:t}):e.path===`/messages`||e.path===`/messages/private`?(0,H.jsx)(ht,{navigate:t}):(0,H.jsx)(ct,{navigate:t})}function Y(){let[e,t]=(0,g.useState)(void 0),[n,r]=(0,g.useState)(()=>Oe());(0,g.useEffect)(()=>{let e=()=>r(Oe());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,g.useEffect)(()=>{x.session().then(e=>t(e.actor)).catch(e=>{if(e instanceof v&&e.status===401){t(null);return}t(null)})},[]);let i=e=>{window.history.pushState(null,``,e),r(Oe())};return e===void 0?(0,H.jsx)(Me,{}):e===null?(0,H.jsx)(Ze,{onLogin:t}):(0,H.jsx)(Ne,{actor:e,route:n,navigate:i,onLogout:()=>t(null),children:(0,H.jsx)(Mt,{route:n,navigate:i})})}_.createRoot(document.getElementById(`root`)).render((0,H.jsx)(g.StrictMode,{children:(0,H.jsx)(Se,{children:(0,H.jsx)(Y,{})})})); \ No newline at end of file diff --git a/cmd/telesrv-admin/web/dist/index.html b/cmd/telesrv-admin/web/dist/index.html index 9393d97c..6dd646aa 100644 --- a/cmd/telesrv-admin/web/dist/index.html +++ b/cmd/telesrv-admin/web/dist/index.html @@ -1,13 +1,13 @@ - - - - - - telesrv admin - - - - -
- - + + + + + + telesrv admin + + + + +
+ + diff --git a/cmd/telesrv-admin/web/src/api.ts b/cmd/telesrv-admin/web/src/api.ts index dd4a3245..75bc7d79 100644 --- a/cmd/telesrv-admin/web/src/api.ts +++ b/cmd/telesrv-admin/web/src/api.ts @@ -8,6 +8,7 @@ import type { GroupMessageListResponse, MessageDetail, MessageListResponse, + OfficialStarGiftListResponse, StarGiftCollectiblePreview, StarGiftListResponse } from "./types"; @@ -66,11 +67,14 @@ export const api = { return request(`/api/messages/groups/detail?${params.toString()}`); }, gifts: () => request("/api/gifts"), - giftAnimation: (id: number) => request>(`/api/gifts/${id}/animation`), - giftCollectibles: (id: number) => request(`/api/gifts/${id}/collectibles`), - giftCollectibleAnimation: (giftID: number, kind: "model" | "pattern", attributeID: number) => request>(`/api/gifts/${giftID}/collectibles/${kind}/${attributeID}/animation`), + officialGifts: () => request("/api/official-gifts"), + officialGiftAnimation: (id: string) => request>(`/api/official-gifts/${encodeURIComponent(id)}/animation`), + giftAnimation: (id: string) => request>(`/api/gifts/${encodeURIComponent(id)}/animation`), + giftCollectibles: (id: string) => request(`/api/gifts/${encodeURIComponent(id)}/collectibles`), + giftCollectibleAnimation: (giftID: string, kind: "model" | "pattern", attributeID: string) => request>(`/api/gifts/${encodeURIComponent(giftID)}/collectibles/${kind}/${encodeURIComponent(attributeID)}/animation`), importGift: (form: FormData) => request("/api/actions/import-gift", { method: "POST", body: form }), - publishGiftCollectibles: (giftID: number, form: FormData) => request(`/api/actions/publish-gift-collectibles?gift_id=${giftID}`, { method: "POST", body: form }), + importOfficialGift: (payload: Record) => request("/api/actions/import-official-gift", { method: "POST", body: JSON.stringify(payload) }), + publishGiftCollectibles: (giftID: string, form: FormData) => request(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(giftID)}`, { method: "POST", body: form }), action: (path: string, payload: Record) => request(path, { method: "POST", body: JSON.stringify(payload) diff --git a/cmd/telesrv-admin/web/src/i18n.tsx b/cmd/telesrv-admin/web/src/i18n.tsx index bfeb0ace..9a53b3d4 100644 --- a/cmd/telesrv-admin/web/src/i18n.tsx +++ b/cmd/telesrv-admin/web/src/i18n.tsx @@ -260,6 +260,26 @@ const translations: Record> = { "gifts.importEyebrow": "Gift catalog operation", "gifts.newRevision": "Create revision for gift #{id}", "gifts.importHint": "Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.", + "gifts.officialSource": "Official snapshot", + "gifts.fileSource": "Upload file", + "gifts.officialHint": "Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically.", + "gifts.officialSearch": "Search official gift ID or title", + "gifts.officialSelect": "Choose an official gift", + "gifts.officialRequired": "Choose an official gift first", + "gifts.officialResults": "Showing {shown} of {total}", + "gifts.officialCategoryLabel": "Official gift capability category", + "gifts.officialCategory.all": "All", + "gifts.officialCategory.upgrade": "Upgradable", + "gifts.officialCategory.craft": "Craftable", + "gifts.officialCategory.basic": "Not upgradable", + "gifts.officialUnnamed": "Unnamed official gift #{id}", + "gifts.officialAttributes": "{count} attributes", + "gifts.canUpgrade": "Can upgrade", + "gifts.cannotUpgrade": "Cannot upgrade", + "gifts.canCraft": "Can Craft", + "gifts.cannotCraft": "Cannot Craft", + "gifts.officialEmpty": "No official gifts match this category and search.", + "gifts.includeCollectible": "Import the complete collectible pool, including crafted models", "gifts.animation": "Animation file", "gifts.filePrompt": "Drop or choose a TGS / Lottie file", "gifts.fileHint": "TGS, JSON or Lottie · validated before import", @@ -307,7 +327,7 @@ const translations: Record> = { "collectibles.pattern": "Pattern", "collectibles.backdrop": "Backdrop", "collectibles.rarity": "Rarity ‰", - "collectibles.rarityHint": "Every section must total exactly 1000‰.", + "collectibles.rarityHint": "Permille values are relative regular-upgrade weights; their total does not need to equal 1000.", "collectibles.colorHint": "Colors are stored as Telegram 24-bit RGB values.", "collectibles.addAttribute": "Add", "collectibles.remove": "Remove attribute", @@ -610,6 +630,26 @@ const translations: Record> = { "gifts.importEyebrow": "礼物目录操作", "gifts.newRevision": "为礼物 #{id} 创建新版本", "gifts.importHint": "支持 TGS 或纯 Lottie JSON;Lottie 会规范化并压缩成 TGS。", + "gifts.officialSource": "官方资源库", + "gifts.fileSource": "上传文件", + "gifts.officialHint": "从 data/official-gifts 的已校验快照中选择;完整 collectible 属性池会与礼物原子导入。", + "gifts.officialSearch": "搜索官方礼物 ID 或标题", + "gifts.officialSelect": "请选择官方礼物", + "gifts.officialRequired": "请先选择一个官方礼物", + "gifts.officialResults": "显示 {shown} / {total} 项", + "gifts.officialCategoryLabel": "官方礼物能力分类", + "gifts.officialCategory.all": "全部", + "gifts.officialCategory.upgrade": "可升级", + "gifts.officialCategory.craft": "可 Craft", + "gifts.officialCategory.basic": "不可升级", + "gifts.officialUnnamed": "未命名官方礼物 #{id}", + "gifts.officialAttributes": "{count} 个属性", + "gifts.canUpgrade": "可升级", + "gifts.cannotUpgrade": "不可升级", + "gifts.canCraft": "可 Craft", + "gifts.cannotCraft": "不可 Craft", + "gifts.officialEmpty": "当前分类和搜索条件下没有官方礼物。", + "gifts.includeCollectible": "完整导入 collectible 属性池(包含 crafted 模型)", "gifts.animation": "动画文件", "gifts.filePrompt": "拖放或选择 TGS / Lottie 文件", "gifts.fileHint": "支持 TGS、JSON、Lottie,导入前会先进行校验", @@ -657,7 +697,7 @@ const translations: Record> = { "collectibles.pattern": "图案", "collectibles.backdrop": "背景", "collectibles.rarity": "稀有度 ‰", - "collectibles.rarityHint": "每一类的稀有度总和必须正好为 1000‰。", + "collectibles.rarityHint": "Permille 是普通升级的相对权重,不要求每类合计正好为 1000。", "collectibles.colorHint": "颜色会按 Telegram 24 位 RGB 数值保存。", "collectibles.addAttribute": "添加", "collectibles.remove": "删除属性", @@ -960,6 +1000,26 @@ const translations: Record> = { "gifts.importEyebrow": "Управление каталогом подарков", "gifts.newRevision": "Создать версию для подарка #{id}", "gifts.importHint": "Загрузите файл TGS или обычный Lottie JSON. Lottie нормализуется и сжимается в формат TGS.", + "gifts.officialSource": "Официальный снимок", + "gifts.fileSource": "Загрузить файл", + "gifts.officialHint": "Выберите проверенный подарок из data/official-gifts. Полные пулы коллекционных предметов импортируются атомарно.", + "gifts.officialSearch": "Поиск по ID или названию официального подарка", + "gifts.officialSelect": "Выберите официальный подарок", + "gifts.officialRequired": "Сначала выберите официальный подарок", + "gifts.officialResults": "Показано {shown} из {total}", + "gifts.officialCategoryLabel": "Категория возможностей официального подарка", + "gifts.officialCategory.all": "Все", + "gifts.officialCategory.upgrade": "Можно улучшить", + "gifts.officialCategory.craft": "Можно создать", + "gifts.officialCategory.basic": "Нельзя улучшить", + "gifts.officialUnnamed": "Официальный подарок без названия #{id}", + "gifts.officialAttributes": "Атрибутов: {count}", + "gifts.canUpgrade": "Можно улучшить", + "gifts.cannotUpgrade": "Нельзя улучшить", + "gifts.canCraft": "Можно создать", + "gifts.cannotCraft": "Нельзя создать", + "gifts.officialEmpty": "Нет подарков, соответствующих категории и поиску.", + "gifts.includeCollectible": "Импортировать полный пул коллекционных предметов, включая созданные модели", "gifts.animation": "Файл анимации", "gifts.filePrompt": "Перетащите или выберите файл TGS / Lottie", "gifts.fileHint": "TGS, JSON или Lottie · файл проверяется перед импортом", @@ -1007,7 +1067,7 @@ const translations: Record> = { "collectibles.pattern": "Узор", "collectibles.backdrop": "Фон", "collectibles.rarity": "Редкость ‰", - "collectibles.rarityHint": "Сумма по каждому разделу должна составлять ровно 1000‰.", + "collectibles.rarityHint": "Значения permille — это относительные веса обычного улучшения; их сумма не обязана равняться 1000.", "collectibles.colorHint": "Цвета сохраняются как 24-битные RGB-значения Telegram.", "collectibles.addAttribute": "Добавить", "collectibles.remove": "Удалить атрибут", diff --git a/cmd/telesrv-admin/web/src/pages/GiftCollectiblesModal.tsx b/cmd/telesrv-admin/web/src/pages/GiftCollectiblesModal.tsx index 3ef43e09..c6bbe6ff 100644 --- a/cmd/telesrv-admin/web/src/pages/GiftCollectiblesModal.tsx +++ b/cmd/telesrv-admin/web/src/pages/GiftCollectiblesModal.tsx @@ -44,7 +44,7 @@ function AnimationPreview({ data, compact = false }: { data: AnimationData; comp return
; } -function RemoteAnimation({ giftID, attribute }: { giftID: number; attribute: StarGiftCollectibleAttributeRow }) { +function RemoteAnimation({ giftID, attribute }: { giftID: string; attribute: StarGiftCollectibleAttributeRow }) { const [data, setData] = useState(null); const [failed, setFailed] = useState(false); useEffect(() => { @@ -74,6 +74,7 @@ async function parseAnimationFile(file: File): Promise { } const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16); +const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}‰` : attribute.rarity_kind; export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) { const { t } = useI18n(); @@ -135,7 +136,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St const animatedMetadata = (rows: AnimatedDraft[]) => rows.map((row) => ({ name: row.name.trim(), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), file_key: row.key })); form.set("metadata", JSON.stringify({ command_id: commandID, reason: reason.trim(), confirm, - upgrade_stars: Number(upgradeStars), supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(), + upgrade_stars: upgradeStars, supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(), models: animatedMetadata(models), patterns: animatedMetadata(patterns), backdrops: backdrops.map((row) => ({ name: row.name.trim(), backdrop_id: Number(row.backdropID), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), @@ -167,7 +168,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
{t(`collectibles.${kind}`)}{t("collectibles.rarityHint")}
-
{rarityTotals[kind]} / 1000
+
0 ? "good" : "neutral"}>{rarityTotals[kind]}‰
{rows.map((row, index) =>
@@ -194,8 +195,8 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St {loading ?
{t("common.loading")}
: active?.found ?
{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}
{t("collectibles.published")}
- {[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) =>
{attribute.name}{t(`collectibles.${attribute.kind}`)} · {attribute.rarity_permille}‰
)} - {(active.backdrops ?? []).map((attribute) =>
Aa
{attribute.name}{t("collectibles.backdrop")} · {attribute.rarity_permille}‰
)} + {[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) =>
{attribute.name}{attribute.crafted && crafted}{t(`collectibles.${attribute.kind}`)} · {rarityLabel(attribute)}
)} + {(active.backdrops ?? []).map((attribute) =>
Aa
{attribute.name}{t("collectibles.backdrop")} · {rarityLabel(attribute)}
)}
:
{t("collectibles.noPool")}{t("collectibles.noPoolHint")}
} @@ -210,11 +211,11 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St {renderAnimatedRows("models", models, setModels)} {renderAnimatedRows("patterns", patterns, setPatterns)}
-
{t("collectibles.backdrops")}{t("collectibles.colorHint")}
{rarityTotals.backdrops} / 1000
+
{t("collectibles.backdrops")}{t("collectibles.colorHint")}
0 ? "good" : "neutral"}>{rarityTotals.backdrops}‰
{backdrops.map((row, index) =>
{index + 1}
- + {(["center", "edge", "pattern", "text"] as const).map((field) => )} diff --git a/cmd/telesrv-admin/web/src/pages/GiftsPage.tsx b/cmd/telesrv-admin/web/src/pages/GiftsPage.tsx index 79e2b53e..c6441b18 100644 --- a/cmd/telesrv-admin/web/src/pages/GiftsPage.tsx +++ b/cmd/telesrv-admin/web/src/pages/GiftsPage.tsx @@ -7,16 +7,23 @@ import { ActionButton } from "../components/ActionButton"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { useI18n } from "../i18n"; import { formatDate } from "../lib/format"; -import type { CommandResult, StarGiftRow } from "../types"; +import type { CommandResult, OfficialStarGiftRow, StarGiftRow } from "../types"; import { GiftCollectiblesModal } from "./GiftCollectiblesModal"; -function formatBytes(bytes: number) { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic"; + +function officialGiftAttributeCount(gift: OfficialStarGiftRow) { + return gift.model_count + gift.pattern_count + gift.backdrop_count; } -function LottiePreview({ giftID, revision, compact = false }: { giftID: number; revision: number; compact?: boolean }) { +function formatBytes(value: number | string) { + const bytes = Number(value); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) { const host = useRef(null); const animation = useRef | null>(null); const [playing, setPlaying] = useState(true); @@ -59,6 +66,20 @@ function LottiePreview({ giftID, revision, compact = false }: { giftID: number; ); } +function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) { + const host = useRef(null); + useEffect(() => { + let cancelled = false; + let player: ReturnType | null = null; + api.officialGiftAnimation(sourceGiftID).then((data) => { + if (cancelled || !host.current) return; + player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) }); + }).catch(() => undefined); + return () => { cancelled = true; player?.destroy(); }; + }, [sourceGiftID]); + return
; +} + export function GiftsPage() { const { t } = useI18n(); const [gifts, setGifts] = useState([]); @@ -66,7 +87,16 @@ export function GiftsPage() { const [importOpen, setImportOpen] = useState(false); const [collectibleGift, setCollectibleGift] = useState(null); const [file, setFile] = useState(null); - const [giftID, setGiftID] = useState(0); + const [importSource, setImportSource] = useState<"official" | "file">("official"); + const [officialGifts, setOfficialGifts] = useState([]); + const [officialQuery, setOfficialQuery] = useState(""); + const [officialCategory, setOfficialCategory] = useState("all"); + const [sourceGiftID, setSourceGiftID] = useState(""); + const [includeCollectible, setIncludeCollectible] = useState(true); + const [upgradeStars, setUpgradeStars] = useState("0"); + const [supplyTotal, setSupplyTotal] = useState("0"); + const [slugPrefix, setSlugPrefix] = useState(""); + const [giftID, setGiftID] = useState("0"); const [title, setTitle] = useState(""); const [stars, setStars] = useState("50"); const [convertStars, setConvertStars] = useState("50"); @@ -89,6 +119,29 @@ export function GiftsPage() { useEffect(() => { void load(); }, []); + useEffect(() => { + if (!importOpen || importSource !== "official" || officialGifts.length > 0) return; + api.officialGifts().then((value) => setOfficialGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err))); + }, [importOpen, importSource, officialGifts.length]); + + const selectedOfficial = useMemo(() => officialGifts.find((gift) => gift.source_gift_id === sourceGiftID) ?? null, [officialGifts, sourceGiftID]); + const officialCategoryCounts = useMemo(() => ({ + all: officialGifts.length, + upgrade: officialGifts.filter((gift) => gift.can_upgrade).length, + craft: officialGifts.filter((gift) => gift.can_craft).length, + basic: officialGifts.filter((gift) => !gift.can_upgrade).length + }), [officialGifts]); + const visibleOfficial = useMemo(() => { + const normalized = officialQuery.trim().toLowerCase(); + return officialGifts.filter((gift) => { + const categoryMatches = officialCategory === "all" || + (officialCategory === "upgrade" && gift.can_upgrade) || + (officialCategory === "craft" && gift.can_craft) || + (officialCategory === "basic" && !gift.can_upgrade); + return categoryMatches && (!normalized || gift.source_gift_id.includes(normalized) || gift.title.toLowerCase().includes(normalized)); + }); + }, [officialGifts, officialQuery, officialCategory]); + const visibleGifts = useMemo(() => { const normalized = query.trim().toLowerCase(); if (!normalized) return gifts; @@ -107,10 +160,10 @@ export function GiftsPage() { command_id: commandID, reason: reason.trim(), confirm, - gift_id: giftID, - title: title.trim(), - stars: Number(stars), - convert_stars: Number(convertStars), + gift_id: giftID, + title: title.trim(), + stars, + convert_stars: convertStars, enabled, sort_order: Number(sortOrder) })); @@ -118,10 +171,34 @@ export function GiftsPage() { return form; } + function officialPayload(confirm: boolean, commandID = "") { + if (!sourceGiftID) throw new Error(t("gifts.officialRequired")); + if (!reason.trim()) throw new Error(t("action.reasonRequired")); + return { + command_id: commandID, reason: reason.trim(), confirm, + source_gift_id: sourceGiftID, gift_id: giftID, title: title.trim(), + stars, convert_stars: convertStars, enabled, sort_order: Number(sortOrder), + include_collectible: includeCollectible, upgrade_stars: upgradeStars, + supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase() + }; + } + + function chooseOfficial(gift: OfficialStarGiftRow) { + setSourceGiftID(gift.source_gift_id); + setTitle(gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id })); + setStars(String(gift.stars)); + setConvertStars(String(gift.convert_stars)); + setIncludeCollectible(gift.can_upgrade); + setUpgradeStars(gift.upgrade_stars); + setSupplyTotal(String(gift.availability_total || 1)); + setSlugPrefix(`official-${gift.source_gift_id}`); + setPreview(null); + } + async function validateImport() { setBusy(true); setImportError(""); setPreview(null); try { - setPreview(await api.importGift(uploadForm(false))); + setPreview(importSource === "official" ? await api.importOfficialGift(officialPayload(false)) : await api.importGift(uploadForm(false))); } catch (err) { setImportError(errorMessage(err)); } finally { setBusy(false); } @@ -131,8 +208,9 @@ export function GiftsPage() { if (!preview) return; setBusy(true); setImportError(""); try { - await api.importGift(uploadForm(true, preview.command_id)); - setPreview(null); setFile(null); setGiftID(0); setTitle(""); + if (importSource === "official") await api.importOfficialGift(officialPayload(true, preview.command_id)); + else await api.importGift(uploadForm(true, preview.command_id)); + setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSourceGiftID(""); await load(); setImportOpen(false); } catch (err) { @@ -141,14 +219,16 @@ export function GiftsPage() { } function startImport() { - setGiftID(0); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0"); - setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true); + setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0"); + setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError(""); + setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true); } function startRevision(gift: StarGiftRow) { setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars)); setConvertStars(String(gift.ConvertStars)); setSortOrder(String(gift.SortOrder)); setEnabled(gift.Enabled); - setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true); + setReason(""); setFile(null); setPreview(null); setImportError(""); + setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true); } return ( @@ -160,7 +240,7 @@ export function GiftsPage() {
gift.Enabled).length)} tone="good" /> - sum + gift.ReceivedCount, 0))} /> + sum + BigInt(gift.ReceivedCount), 0n).toString()} />
@@ -193,24 +273,77 @@ export function GiftsPage() { {importOpen && createPortal(
-
+
-
{t("gifts.importEyebrow")}

{giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}

+
{t("gifts.importEyebrow")}

{giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}

-
1{t("gifts.stepDetails")}
-
2{t("gifts.stepValidate")}
+
1{t("gifts.stepDetails")}
+
2{t("gifts.stepValidate")}
3{t("gifts.stepImport")}
-
{t("gifts.importHint")}
TGSLottie JSON
- +
+ + +
+ {importSource === "official" ?
+
{t("gifts.officialHint")}
{officialGifts.length}SHA-256
+
+ + {t("gifts.officialResults", { shown: visibleOfficial.length, total: officialGifts.length })} +
+
+ {(["all", "upgrade", "craft", "basic"] as const).map((category) => ( + + ))} +
+
+ {visibleOfficial.map((gift) => { + const selected = gift.source_gift_id === sourceGiftID; + return ; + })} + {visibleOfficial.length === 0 &&
{t("gifts.officialEmpty")}
} +
+ {selectedOfficial &&
+ +
{selectedOfficial.title || t("gifts.officialUnnamed", { id: selectedOfficial.source_gift_id })}{selectedOfficial.source_gift_id}{selectedOfficial.model_count} {t("collectibles.models")} · {selectedOfficial.pattern_count} {t("collectibles.patterns")} · {selectedOfficial.backdrop_count} {t("collectibles.backdrops")}{selectedOfficial.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}{selectedOfficial.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}
+
} + {selectedOfficial?.can_upgrade && <> + + {includeCollectible &&
+ + + +
} + } +
: <> +
{t("gifts.importHint")}
TGSLottie JSON
+ + }
diff --git a/cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css b/cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css index 0c6b0cb9..9d1b5684 100644 --- a/cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css +++ b/cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css @@ -276,6 +276,60 @@ .gift-import-modal { width: min(860px, 100%); } .gift-import-modal-body { gap: 14px; } +.gift-source-tabs { display: flex; gap: 8px; } +.official-gift-picker { display: grid; min-width: 0; gap: 12px; } +.official-gift-tools { display: flex; align-items: center; gap: 12px; } +.official-gift-tools .searchbox { width: 100%; } +.official-gift-tools > span { flex: 0 0 auto; color: var(--muted); font-size: 11px; font-weight: 750; } +.official-gift-categories { display: flex; flex-wrap: wrap; gap: 7px; } +.official-gift-categories button { + display: inline-flex; align-items: center; gap: 7px; min-height: 32px; padding: 5px 10px; + color: #49605c; background: #f7faf9; border: 1px solid #d7e2df; border-radius: 999px; + font: inherit; font-size: 11px; font-weight: 800; cursor: pointer; + transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease; +} +.official-gift-categories button:hover { color: var(--brand); border-color: #9fc9c0; } +.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: 0 4px 12px rgba(23, 109, 97, .17); } +.official-gift-categories button span { + display: grid; min-width: 20px; height: 20px; padding: 0 5px; place-items: center; + color: inherit; background: rgba(255,255,255,.65); border-radius: 999px; font-size: 10px; +} +.official-gift-categories button.active span { color: var(--brand); } +.official-gift-list { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 314px; + min-height: 126px; padding: 8px; overflow: auto; border: 1px solid var(--line); border-radius: 14px; + background: #f6f9f8; scrollbar-gutter: stable; +} +.official-gift-option { + display: grid; min-width: 0; gap: 8px; padding: 11px 12px; text-align: left; color: var(--text); + background: #ffffff; border: 1px solid #dce6e3; border-radius: 11px; cursor: pointer; + box-shadow: 0 1px 2px rgba(32, 54, 50, .03); + transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease; +} +.official-gift-option:hover { border-color: #9fc9c0; box-shadow: 0 5px 14px rgba(32, 76, 68, .08); transform: translateY(-1px); } +.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .12), 0 5px 14px rgba(32, 76, 68, .08); } +.official-gift-option-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: baseline; } +.official-gift-option-head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } +.official-gift-option-head .mono { color: var(--muted); font-size: 9px; } +.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: #667773; font-size: 10px; font-weight: 700; } +.official-gift-capabilities { display: flex; flex-wrap: wrap; gap: 5px; } +.official-gift-capabilities > span { + padding: 3px 7px; border: 1px solid transparent; border-radius: 999px; font-size: 9px; font-weight: 850; letter-spacing: .01em; +} +.official-gift-capabilities > span.yes { color: #136b4d; background: #e9f8f0; border-color: #bde6cf; } +.official-gift-capabilities > span.craft { color: #6e3ca0; background: #f3ebfb; border-color: #d9c5ef; } +.official-gift-capabilities > span.no { color: #78837f; background: #f1f3f2; border-color: #dde2e0; } +.official-gift-empty { + display: grid; grid-column: 1 / -1; min-height: 108px; place-items: center; padding: 20px; + color: var(--muted); text-align: center; font-size: 12px; +} +.official-gift-selected { + display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 14px; align-items: center; + padding: 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-soft); +} +.official-gift-selected .gift-animation-shell { width: 96px; height: 96px; } +.official-gift-selected > div:last-child { display: grid; gap: 5px; min-width: 0; } +.official-gift-selected small { color: var(--muted); } .gift-import-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); line-height: 1.45; } .gift-file-picker { @@ -321,6 +375,7 @@ .gift-fields-grid input, .gift-reason-field input { + width: 100%; min-width: 0; height: 38px; padding: 0 10px; @@ -440,6 +495,10 @@ .gift-file-action { display: none; } .gift-fields-grid { grid-template-columns: 1fr; } .gift-list-summary { width: 100%; margin-left: 0; } + .official-gift-tools { align-items: stretch; flex-direction: column; } + .official-gift-list { grid-template-columns: 1fr; max-height: 340px; } + .official-gift-selected { grid-template-columns: 82px minmax(0, 1fr); } + .official-gift-selected .gift-animation-shell { width: 72px; height: 72px; } .collectible-modal-body { padding: 10px; } .collectible-definition-head, .collectible-section-head { align-items: flex-start; flex-direction: column; } diff --git a/cmd/telesrv-admin/web/src/types.ts b/cmd/telesrv-admin/web/src/types.ts index f95ca7fd..be00b027 100644 --- a/cmd/telesrv-admin/web/src/types.ts +++ b/cmd/telesrv-admin/web/src/types.ts @@ -161,34 +161,58 @@ export type OutboxRow = { }; export type StarGiftRow = { - GiftID: number; - RevisionID: number; + GiftID: string; + RevisionID: string; Revision: number; Title: string; - Stars: number; - ConvertStars: number; + Stars: string; + ConvertStars: string; Enabled: boolean; SortOrder: number; - DocumentID: number; + DocumentID: string; SourceName: string; SourceFormat: "tgs" | "lottie"; AnimationSHA: string; - AnimationSize: number; + AnimationSize: string; Width: number; Height: number; FrameRate: number; - ReceivedCount: number; + ReceivedCount: string; CreatedBy: string; UpdatedAt: string; }; export type StarGiftListResponse = { Gifts: StarGiftRow[] }; +export type OfficialStarGiftRow = { + source_gift_id: string; + title: string; + stars: string; + convert_stars: string; + upgrade_stars: string; + availability_total: number; + limited: boolean; + sold_out: boolean; + model_count: number; + pattern_count: number; + backdrop_count: number; + crafted_model_count: number; + can_upgrade: boolean; + can_craft: boolean; + document_id: string; + animation_validated: boolean; +}; + +export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] }; + export type StarGiftCollectibleAttributeRow = { - id: number; + id: string; kind: "model" | "pattern" | "backdrop"; name: string; + rarity_kind: "permille" | "uncommon" | "rare" | "epic" | "legendary"; rarity_permille: number; + crafted: boolean; + official_document_id: string; sort_order: number; source_name?: string; source_format?: "tgs" | "lottie"; @@ -201,9 +225,9 @@ export type StarGiftCollectibleAttributeRow = { export type StarGiftCollectiblePreview = { found: boolean; - gift_id: number; + gift_id: string; revision?: number; - upgrade_stars?: number; + upgrade_stars?: string; supply_total?: number; issued?: number; slug_prefix?: string; diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 8140ceec..c9f793c5 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -1,4 +1,4 @@ -// Command telesrv 是基于 github.com/iamxvbaba/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。 +// Command telesrv 是基于 gotd/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。 package main import ( @@ -56,6 +56,7 @@ import ( "telesrv/internal/config" "telesrv/internal/domain" "telesrv/internal/mtprotoedge" + "telesrv/internal/officialgifts" "telesrv/internal/otpdelivery" otpsmtp "telesrv/internal/otpdelivery/smtp" otpwebhook "telesrv/internal/otpdelivery/webhook" @@ -467,8 +468,9 @@ func run(logger *zap.Logger) error { rateLimiter := redisstore.NewRateLimiter(rdb) activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions")) adminService := adminapp.NewService(adminapp.Dependencies{ - Commands: adminStore, - Restrictions: adminStore, + Commands: adminStore, + Restrictions: adminStore, + OfficialGifts: officialgifts.New(cfg.OfficialGiftsDir), }) go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"), cfg.UpdateEventRetention, @@ -663,9 +665,26 @@ func run(logger *zap.Logger) error { starsStore := postgres.NewStarsStore(pool) starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant)) starGiftStore := postgres.NewStarGiftStore(pool) - starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore) + starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{ + TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars, + OfferMinStars: cfg.StarGiftOfferMinStars, + ExportDelaySeconds: int(cfg.StarGiftExportDelay / time.Second), TransferDelaySeconds: int(cfg.StarGiftTransferDelay / time.Second), + ResellDelaySeconds: int(cfg.StarGiftResellDelay / time.Second), CraftDelaySeconds: int(cfg.StarGiftCraftDelay / time.Second), + CraftChancePermille: cfg.StarGiftCraftChancePermille, + })) + starGiftLifecycleStore := postgres.NewStarGiftLifecycleStore(pool, messageStore, cfg.StarGiftTONStartingGrant, + postgres.WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{ + StarsProceedsPermille: cfg.StarGiftStarsProceedsPermille, + TONProceedsPermille: cfg.StarGiftTONProceedsPermille, + })) + starGiftWithdrawalProvider, err := stargifts.NewLocalWithdrawalProvider(cfg.PublicBaseURL) + if err != nil { + return fmt.Errorf("init local star gift withdrawal provider: %w", err) + } giftsService := stargifts.NewService(starGiftStore, blobBackend, cfg.DC, - stargifts.WithUpgradeStore(starGiftUpgradeStore)) + stargifts.WithUpgradeStore(starGiftUpgradeStore), + stargifts.WithLifecycleStore(starGiftLifecycleStore), + stargifts.WithWithdrawalProvider(starGiftWithdrawalProvider)) // Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token // 同属进程内一次性凭据,不跨实例)。 passkeyStore := postgres.NewPasskeyStore(pool) @@ -852,6 +871,32 @@ func run(logger *zap.Logger) error { go router.RunPresenceSweeper(ctx, time.Minute) go activeSessions.RunPendingSweeper(ctx, time.Minute) go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch) + go func() { + interval := cfg.StarGiftSweepInterval + if interval <= 0 { + interval = 15 * time.Second + } + batch := cfg.StarGiftSweepBatch + if batch <= 0 { + batch = 1000 + } + run := func() { + if err := giftsService.SweepLifecycle(ctx, int(time.Now().Unix()), batch); err != nil && ctx.Err() == nil { + logger.Warn("star_gift_lifecycle_sweep_failed", zap.Error(err)) + } + } + run() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } + }() go router.RunInlineBotPushSubscriber(ctx) if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil { return fmt.Errorf("start bot api: %w", err) @@ -860,16 +905,18 @@ func run(logger *zap.Logger) error { return fmt.Errorf("start admin api: %w", err) } if _, err := web.Start(ctx, web.Config{ - Addr: cfg.PublicLinkWebAddr, - PublicBaseURL: cfg.PublicBaseURL, - AppScheme: cfg.PublicAppScheme, - WebBaseURL: cfg.PublicWebBaseURL, - AppName: cfg.PublicAppName, - StickerSets: filesService, - Users: userStore, - Channels: channelStore, - Privacy: privacyService, - Photos: filesService, + Addr: cfg.PublicLinkWebAddr, + PublicBaseURL: cfg.PublicBaseURL, + AppScheme: cfg.PublicAppScheme, + WebBaseURL: cfg.PublicWebBaseURL, + AppName: cfg.PublicAppName, + StickerSets: filesService, + Users: userStore, + Channels: channelStore, + Privacy: privacyService, + Photos: filesService, + UniqueGifts: giftsService, + GiftWithdrawals: giftsService, }, logger.Named("public-web")); err != nil { return fmt.Errorf("start public Web: %w", err) } diff --git a/deploy/migrations/0093_official_star_gift_attributes.down.sql b/deploy/migrations/0093_official_star_gift_attributes.down.sql new file mode 100644 index 00000000..b14c344d --- /dev/null +++ b/deploy/migrations/0093_official_star_gift_attributes.down.sql @@ -0,0 +1,72 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM public.star_gift_collectible_models + WHERE rarity_kind <> 'permille' OR crafted + ) THEN + RAISE EXCEPTION 'cannot downgrade while categorical/crafted collectible models exist'; + END IF; +END; +$$; + +DROP INDEX IF EXISTS public.star_gift_catalog_revisions_official_source_idx; + +ALTER TABLE public.star_gift_collectible_backdrops + DROP CONSTRAINT star_gift_collectible_backdrop_rarity_check, + ALTER COLUMN rarity_permille SET NOT NULL, + DROP COLUMN rarity_kind, + ADD CONSTRAINT star_gift_collectible_backdrop_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000); + +ALTER TABLE public.star_gift_collectible_patterns + DROP CONSTRAINT star_gift_collectible_pattern_official_document_check, + DROP CONSTRAINT star_gift_collectible_pattern_rarity_check, + ALTER COLUMN rarity_permille SET NOT NULL, + DROP COLUMN official_document_id, + DROP COLUMN rarity_kind, + ADD CONSTRAINT star_gift_collectible_pattern_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000); + +ALTER TABLE public.star_gift_collectible_models + DROP CONSTRAINT star_gift_collectible_model_official_document_check, + DROP CONSTRAINT star_gift_collectible_model_rarity_check, + ALTER COLUMN rarity_permille SET NOT NULL, + DROP COLUMN official_document_id, + DROP COLUMN crafted, + DROP COLUMN rarity_kind, + ADD CONSTRAINT star_gift_collectible_model_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000); + +ALTER TABLE public.star_gift_collectible_revisions + DROP CONSTRAINT star_gift_collectible_official_source_check, + DROP COLUMN source_manifest_sha256, + DROP COLUMN official_gift_id; + +ALTER TABLE public.star_gift_catalog_revisions + DROP CONSTRAINT star_gift_catalog_official_source_check, + DROP COLUMN official_source, + DROP COLUMN source_manifest_sha256, + DROP COLUMN official_gift_id; + +CREATE OR REPLACE FUNCTION public.telesrv_guard_collectible_revision() RETURNS trigger + LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF OLD.status = 'published' THEN + RAISE EXCEPTION 'published collectible revision is immutable'; + END IF; + RETURN OLD; + END IF; + + IF OLD.status = 'published' THEN + IF NEW.gift_id <> OLD.gift_id OR NEW.revision <> OLD.revision OR + NEW.upgrade_stars <> OLD.upgrade_stars OR NEW.supply_total <> OLD.supply_total OR + NEW.slug_prefix <> OLD.slug_prefix OR NEW.status <> OLD.status OR + NEW.created_by <> OLD.created_by OR NEW.command_id <> OLD.command_id OR + NEW.created_at <> OLD.created_at OR NEW.published_at <> OLD.published_at THEN + RAISE EXCEPTION 'published collectible revision is immutable'; + END IF; + IF NEW.issued <> OLD.issued + 1 THEN + RAISE EXCEPTION 'published collectible issuance must advance exactly once'; + END IF; + END IF; + RETURN NEW; +END; +$$; diff --git a/deploy/migrations/0093_official_star_gift_attributes.up.sql b/deploy/migrations/0093_official_star_gift_attributes.up.sql new file mode 100644 index 00000000..f9168fe3 --- /dev/null +++ b/deploy/migrations/0093_official_star_gift_attributes.up.sql @@ -0,0 +1,88 @@ +-- Preserve the complete Layer 228 official collectible attribute shape. Display rarity is +-- distinct from regular-upgrade selection eligibility, and official provenance is recorded +-- on both immutable revisions created by one import command. + +ALTER TABLE public.star_gift_catalog_revisions + ADD COLUMN official_gift_id bigint, + ADD COLUMN source_manifest_sha256 bytea, + ADD COLUMN official_source jsonb, + ADD CONSTRAINT star_gift_catalog_official_source_check CHECK ( + (official_gift_id IS NULL AND source_manifest_sha256 IS NULL AND official_source IS NULL) OR + (official_gift_id > 0 AND source_manifest_sha256 IS NOT NULL AND official_source IS NOT NULL AND + octet_length(source_manifest_sha256) = 32 AND jsonb_typeof(official_source) = 'object') + ); + +ALTER TABLE public.star_gift_collectible_revisions + ADD COLUMN official_gift_id bigint, + ADD COLUMN source_manifest_sha256 bytea, + ADD CONSTRAINT star_gift_collectible_official_source_check CHECK ( + (official_gift_id IS NULL AND source_manifest_sha256 IS NULL) OR + (official_gift_id > 0 AND source_manifest_sha256 IS NOT NULL AND octet_length(source_manifest_sha256) = 32) + ); + +ALTER TABLE public.star_gift_collectible_models + DROP CONSTRAINT star_gift_collectible_model_rarity_check, + ALTER COLUMN rarity_permille DROP NOT NULL, + ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL, + ADD COLUMN crafted boolean DEFAULT false NOT NULL, + ADD COLUMN official_document_id bigint, + ADD CONSTRAINT star_gift_collectible_model_rarity_check CHECK ( + rarity_kind IN ('permille', 'uncommon', 'rare', 'epic', 'legendary') AND + ((rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000 AND NOT crafted) OR + (rarity_kind <> 'permille' AND rarity_permille IS NULL AND crafted)) + ), + ADD CONSTRAINT star_gift_collectible_model_official_document_check CHECK ( + official_document_id IS NULL OR official_document_id > 0 + ); + +ALTER TABLE public.star_gift_collectible_patterns + DROP CONSTRAINT star_gift_collectible_pattern_rarity_check, + ALTER COLUMN rarity_permille DROP NOT NULL, + ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL, + ADD COLUMN official_document_id bigint, + ADD CONSTRAINT star_gift_collectible_pattern_rarity_check CHECK ( + rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000 + ), + ADD CONSTRAINT star_gift_collectible_pattern_official_document_check CHECK ( + official_document_id IS NULL OR official_document_id > 0 + ); + +ALTER TABLE public.star_gift_collectible_backdrops + DROP CONSTRAINT star_gift_collectible_backdrop_rarity_check, + ALTER COLUMN rarity_permille DROP NOT NULL, + ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL, + ADD CONSTRAINT star_gift_collectible_backdrop_rarity_check CHECK ( + rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000 + ); + +CREATE INDEX star_gift_catalog_revisions_official_source_idx + ON public.star_gift_catalog_revisions(official_gift_id, id DESC) + WHERE official_gift_id IS NOT NULL; + +CREATE OR REPLACE FUNCTION public.telesrv_guard_collectible_revision() RETURNS trigger + LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF OLD.status = 'published' THEN + RAISE EXCEPTION 'published collectible revision is immutable'; + END IF; + RETURN OLD; + END IF; + + IF OLD.status = 'published' THEN + IF NEW.gift_id <> OLD.gift_id OR NEW.revision <> OLD.revision OR + NEW.upgrade_stars <> OLD.upgrade_stars OR NEW.supply_total <> OLD.supply_total OR + NEW.slug_prefix <> OLD.slug_prefix OR NEW.status <> OLD.status OR + NEW.created_by <> OLD.created_by OR NEW.command_id <> OLD.command_id OR + NEW.created_at <> OLD.created_at OR NEW.published_at <> OLD.published_at OR + NEW.official_gift_id IS DISTINCT FROM OLD.official_gift_id OR + NEW.source_manifest_sha256 IS DISTINCT FROM OLD.source_manifest_sha256 THEN + RAISE EXCEPTION 'published collectible revision is immutable'; + END IF; + IF NEW.issued <> OLD.issued + 1 THEN + RAISE EXCEPTION 'published collectible issuance must advance exactly once'; + END IF; + END IF; + RETURN NEW; +END; +$$; diff --git a/deploy/migrations/0094_star_gift_catalog_shape.down.sql b/deploy/migrations/0094_star_gift_catalog_shape.down.sql new file mode 100644 index 00000000..b44323a4 --- /dev/null +++ b/deploy/migrations/0094_star_gift_catalog_shape.down.sql @@ -0,0 +1,34 @@ +DROP TABLE IF EXISTS public.star_gift_user_purchases; + +ALTER TABLE public.star_gift_catalog_revisions + DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_background_check, + DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_auction_check, + DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_released_by_check, + DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_supply_check, + DROP COLUMN IF EXISTS background_text_color, + DROP COLUMN IF EXISTS background_edge_color, + DROP COLUMN IF EXISTS background_center_color, + DROP COLUMN IF EXISTS upgrade_variants, + DROP COLUMN IF EXISTS auction_start_date, + DROP COLUMN IF EXISTS gifts_per_round, + DROP COLUMN IF EXISTS auction_slug, + DROP COLUMN IF EXISTS locked_until_date, + DROP COLUMN IF EXISTS per_user_total, + DROP COLUMN IF EXISTS released_by_peer_id, + DROP COLUMN IF EXISTS released_by_peer_type, + DROP COLUMN IF EXISTS availability_total, + DROP COLUMN IF EXISTS auction, + DROP COLUMN IF EXISTS peer_color_available, + DROP COLUMN IF EXISTS limited_per_user, + DROP COLUMN IF EXISTS require_premium, + DROP COLUMN IF EXISTS birthday, + DROP COLUMN IF EXISTS sold_out, + DROP COLUMN IF EXISTS limited; + +ALTER TABLE public.star_gift_catalog + DROP CONSTRAINT IF EXISTS star_gift_catalog_inventory_check, + DROP COLUMN IF EXISTS last_sale_date, + DROP COLUMN IF EXISTS first_sale_date, + DROP COLUMN IF EXISTS availability_resale, + DROP COLUMN IF EXISTS resell_min_stars, + DROP COLUMN IF EXISTS availability_remains; diff --git a/deploy/migrations/0094_star_gift_catalog_shape.up.sql b/deploy/migrations/0094_star_gift_catalog_shape.up.sql new file mode 100644 index 00000000..3dce2b3f --- /dev/null +++ b/deploy/migrations/0094_star_gift_catalog_shape.up.sql @@ -0,0 +1,68 @@ +-- Preserve the complete Layer 228 regular StarGift shape. Release facts are immutable +-- catalog-revision data; inventory and sale timestamps belong to the mutable catalog +-- aggregate. Per-user ownership limits are enforced by the transaction boundary rather +-- than reconstructed from peer_star_gifts after the fact. + +ALTER TABLE public.star_gift_catalog + ADD COLUMN availability_remains integer DEFAULT 0 NOT NULL, + ADD COLUMN availability_resale bigint DEFAULT 0 NOT NULL, + ADD COLUMN resell_min_stars bigint DEFAULT 0 NOT NULL, + ADD COLUMN first_sale_date integer DEFAULT 0 NOT NULL, + ADD COLUMN last_sale_date integer DEFAULT 0 NOT NULL, + ADD CONSTRAINT star_gift_catalog_inventory_check CHECK ( + availability_remains >= 0 AND availability_resale >= 0 AND resell_min_stars >= 0 AND + first_sale_date >= 0 AND last_sale_date >= 0 AND + (last_sale_date = 0 OR first_sale_date > 0) AND + (first_sale_date = 0 OR last_sale_date = 0 OR last_sale_date >= first_sale_date) + ); + +ALTER TABLE public.star_gift_catalog_revisions + ADD COLUMN limited boolean DEFAULT false NOT NULL, + ADD COLUMN sold_out boolean DEFAULT false NOT NULL, + ADD COLUMN birthday boolean DEFAULT false NOT NULL, + ADD COLUMN require_premium boolean DEFAULT false NOT NULL, + ADD COLUMN limited_per_user boolean DEFAULT false NOT NULL, + ADD COLUMN peer_color_available boolean DEFAULT false NOT NULL, + ADD COLUMN auction boolean DEFAULT false NOT NULL, + ADD COLUMN availability_total integer DEFAULT 0 NOT NULL, + ADD COLUMN released_by_peer_type text, + ADD COLUMN released_by_peer_id bigint, + ADD COLUMN per_user_total integer DEFAULT 0 NOT NULL, + ADD COLUMN locked_until_date integer DEFAULT 0 NOT NULL, + ADD COLUMN auction_slug text DEFAULT '' NOT NULL, + ADD COLUMN gifts_per_round integer DEFAULT 0 NOT NULL, + ADD COLUMN auction_start_date integer DEFAULT 0 NOT NULL, + ADD COLUMN upgrade_variants integer DEFAULT 0 NOT NULL, + ADD COLUMN background_center_color integer, + ADD COLUMN background_edge_color integer, + ADD COLUMN background_text_color integer, + ADD CONSTRAINT star_gift_catalog_revision_supply_check CHECK ( + availability_total >= 0 AND + per_user_total >= 0 AND locked_until_date >= 0 AND upgrade_variants >= 0 AND + ((limited AND availability_total > 0) OR (NOT limited AND availability_total = 0)) AND + (NOT sold_out OR limited) AND + ((limited_per_user AND per_user_total > 0) OR (NOT limited_per_user AND per_user_total = 0)) + ), + ADD CONSTRAINT star_gift_catalog_revision_released_by_check CHECK ( + (released_by_peer_type IS NULL AND released_by_peer_id IS NULL) OR + (released_by_peer_type IN ('user', 'chat', 'channel') AND released_by_peer_id > 0) + ), + ADD CONSTRAINT star_gift_catalog_revision_auction_check CHECK ( + (auction AND limited AND auction_slug <> '' AND gifts_per_round > 0 AND auction_start_date > 0) OR + (NOT auction AND auction_slug = '' AND gifts_per_round = 0 AND auction_start_date = 0) + ), + ADD CONSTRAINT star_gift_catalog_revision_background_check CHECK ( + (background_center_color IS NULL AND background_edge_color IS NULL AND background_text_color IS NULL) OR + (background_center_color BETWEEN 0 AND 16777215 AND + background_edge_color BETWEEN 0 AND 16777215 AND + background_text_color BETWEEN 0 AND 16777215) + ); + +CREATE TABLE public.star_gift_user_purchases ( + user_id bigint NOT NULL, + gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT, + purchased_count integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT star_gift_user_purchases_pkey PRIMARY KEY (user_id, gift_id), + CONSTRAINT star_gift_user_purchases_count_check CHECK (user_id > 0 AND purchased_count >= 0) +); diff --git a/deploy/migrations/0095_star_gift_lifecycle.down.sql b/deploy/migrations/0095_star_gift_lifecycle.down.sql new file mode 100644 index 00000000..7efc8026 --- /dev/null +++ b/deploy/migrations/0095_star_gift_lifecycle.down.sql @@ -0,0 +1,76 @@ +DROP TRIGGER IF EXISTS peer_unique_star_gift_owner_guard ON public.peer_star_gifts; +DROP TRIGGER IF EXISTS unique_star_gift_owner_guard ON public.unique_star_gifts; +DROP FUNCTION IF EXISTS public.telesrv_check_unique_star_gift_owner(); +DROP TRIGGER IF EXISTS star_gift_listing_guard ON public.star_gift_listings; +DROP FUNCTION IF EXISTS public.telesrv_guard_star_gift_listing(); + +DROP TABLE IF EXISTS public.ton_transactions; +DROP TABLE IF EXISTS public.ton_balances; +DROP TABLE IF EXISTS public.star_gift_auction_acquired; +DROP TABLE IF EXISTS public.star_gift_auction_bid_payments; +DROP TABLE IF EXISTS public.star_gift_auction_bids; +DROP TABLE IF EXISTS public.star_gift_auctions; +DROP TABLE IF EXISTS public.star_gift_withdrawal_requests; +DROP TABLE IF EXISTS public.star_gift_notification_settings; +DROP TABLE IF EXISTS public.star_gift_craft_commands; +DROP TABLE IF EXISTS public.star_gift_transfer_commands; +DROP TABLE IF EXISTS public.star_gift_purchase_commands; +DROP TABLE IF EXISTS public.star_gift_drop_details_commands; +DROP TABLE IF EXISTS public.star_gift_prepaid_upgrade_commands; +DROP TABLE IF EXISTS public.star_gift_offers; +DROP TABLE IF EXISTS public.star_gift_sales; +DROP TABLE IF EXISTS public.star_gift_listings; + +ALTER TABLE public.unique_star_gifts + DROP CONSTRAINT IF EXISTS unique_star_gift_value_check, + DROP CONSTRAINT IF EXISTS unique_star_gift_original_owner_check, + DROP CONSTRAINT IF EXISTS unique_star_gift_host_peer_check, + DROP CONSTRAINT IF EXISTS unique_star_gift_theme_peer_check, + DROP CONSTRAINT IF EXISTS unique_star_gift_released_by_check, + DROP CONSTRAINT IF EXISTS unique_star_gift_owner_check, + DROP COLUMN IF EXISTS last_sale_amount, + DROP COLUMN IF EXISTS last_sale_currency, + DROP COLUMN IF EXISTS last_sale_date, + DROP COLUMN IF EXISTS craft_chance_permille, + DROP COLUMN IF EXISTS offer_min_stars, + DROP COLUMN IF EXISTS host_peer_id, + DROP COLUMN IF EXISTS host_peer_type, + DROP COLUMN IF EXISTS theme_peer_id, + DROP COLUMN IF EXISTS theme_peer_type, + DROP COLUMN IF EXISTS value_usd_amount, + DROP COLUMN IF EXISTS value_currency, + DROP COLUMN IF EXISTS value_amount, + DROP COLUMN IF EXISTS released_by_peer_id, + DROP COLUMN IF EXISTS released_by_peer_type, + DROP COLUMN IF EXISTS gift_address, + DROP COLUMN IF EXISTS owner_address, + DROP COLUMN IF EXISTS owner_name, + DROP COLUMN IF EXISTS crafted, + DROP COLUMN IF EXISTS original_owner_peer_id, + DROP COLUMN IF EXISTS original_owner_peer_type, + DROP COLUMN IF EXISTS burned, + DROP COLUMN IF EXISTS theme_available, + DROP COLUMN IF EXISTS resale_ton_only, + DROP COLUMN IF EXISTS require_premium; + +UPDATE public.unique_star_gifts u +SET owner_peer_type=p.owner_peer_type, owner_peer_id=p.owner_peer_id +FROM public.peer_star_gifts p +WHERE p.unique_gift_id=u.id AND (u.owner_peer_type IS NULL OR u.owner_peer_id IS NULL); +ALTER TABLE public.unique_star_gifts + ALTER COLUMN owner_peer_type SET NOT NULL, + ALTER COLUMN owner_peer_id SET NOT NULL, + ADD CONSTRAINT unique_star_gift_owner_check CHECK (owner_peer_type IN ('user','channel') AND owner_peer_id>0); + +ALTER TABLE public.peer_star_gifts + DROP COLUMN IF EXISTS prepaid_upgrade_hash, + DROP COLUMN IF EXISTS gift_num, + DROP CONSTRAINT IF EXISTS peer_star_gifts_lifecycle_check, + DROP COLUMN IF EXISTS can_craft_at, + DROP COLUMN IF EXISTS drop_original_details_stars, + DROP COLUMN IF EXISTS can_resell_at, + DROP COLUMN IF EXISTS can_transfer_at, + DROP COLUMN IF EXISTS can_export_at, + DROP COLUMN IF EXISTS transfer_stars, + DROP COLUMN IF EXISTS lifecycle_status, + ADD CONSTRAINT peer_star_gifts_terminal_state_check CHECK (NOT converted OR unique_gift_id IS NULL); diff --git a/deploy/migrations/0095_star_gift_lifecycle.up.sql b/deploy/migrations/0095_star_gift_lifecycle.up.sql new file mode 100644 index 00000000..162775ba --- /dev/null +++ b/deploy/migrations/0095_star_gift_lifecycle.up.sql @@ -0,0 +1,425 @@ +-- Complete collectible Star Gift lifecycle: ownership state, transfer/resale, purchase +-- offers, crafting, auctions, notification preferences and the explicit TON boundary. + +ALTER TABLE public.peer_star_gifts + DROP CONSTRAINT IF EXISTS peer_star_gifts_terminal_state_check, + ADD COLUMN lifecycle_status text DEFAULT 'active' NOT NULL, + ADD COLUMN transfer_stars bigint DEFAULT 0 NOT NULL, + ADD COLUMN prepaid_upgrade_hash text DEFAULT '' NOT NULL, + ADD COLUMN gift_num integer DEFAULT 0 NOT NULL, + ADD COLUMN can_export_at integer DEFAULT 0 NOT NULL, + ADD COLUMN can_transfer_at integer DEFAULT 0 NOT NULL, + ADD COLUMN can_resell_at integer DEFAULT 0 NOT NULL, + ADD COLUMN drop_original_details_stars bigint DEFAULT 0 NOT NULL, + ADD COLUMN can_craft_at integer DEFAULT 0 NOT NULL; + +UPDATE public.peer_star_gifts SET lifecycle_status='converted' WHERE converted; + +ALTER TABLE public.peer_star_gifts + ADD CONSTRAINT peer_star_gifts_lifecycle_check CHECK ( + lifecycle_status IN ('active', 'converted', 'burned', 'exported') AND + transfer_stars >= 0 AND gift_num >= 0 AND can_export_at >= 0 AND can_transfer_at >= 0 AND + can_resell_at >= 0 AND drop_original_details_stars >= 0 AND can_craft_at >= 0 AND + ((lifecycle_status='converted' AND converted AND unique_gift_id IS NULL) OR + (lifecycle_status='active' AND NOT converted) OR + (lifecycle_status IN ('burned','exported') AND NOT converted AND unique_gift_id IS NOT NULL)) + ); + +CREATE UNIQUE INDEX peer_star_gifts_prepaid_upgrade_hash_uniq + ON public.peer_star_gifts(prepaid_upgrade_hash) WHERE prepaid_upgrade_hash<>''; + +ALTER TABLE public.unique_star_gifts + ALTER COLUMN owner_peer_type DROP NOT NULL, + ALTER COLUMN owner_peer_id DROP NOT NULL, + DROP CONSTRAINT IF EXISTS unique_star_gift_owner_check, + ADD COLUMN require_premium boolean DEFAULT false NOT NULL, + ADD COLUMN resale_ton_only boolean DEFAULT false NOT NULL, + ADD COLUMN theme_available boolean DEFAULT false NOT NULL, + ADD COLUMN burned boolean DEFAULT false NOT NULL, + ADD COLUMN crafted boolean DEFAULT false NOT NULL, + ADD COLUMN original_owner_peer_type text, + ADD COLUMN original_owner_peer_id bigint, + ADD COLUMN owner_name text DEFAULT '' NOT NULL, + ADD COLUMN owner_address text DEFAULT '' NOT NULL, + ADD COLUMN gift_address text DEFAULT '' NOT NULL, + ADD COLUMN released_by_peer_type text, + ADD COLUMN released_by_peer_id bigint, + ADD COLUMN value_amount bigint DEFAULT 0 NOT NULL, + ADD COLUMN value_currency text DEFAULT '' NOT NULL, + ADD COLUMN value_usd_amount bigint DEFAULT 0 NOT NULL, + ADD COLUMN theme_peer_type text, + ADD COLUMN theme_peer_id bigint, + ADD COLUMN host_peer_type text, + ADD COLUMN host_peer_id bigint, + ADD COLUMN offer_min_stars integer DEFAULT 0 NOT NULL, + ADD COLUMN craft_chance_permille integer DEFAULT 0 NOT NULL, + ADD COLUMN last_sale_date integer DEFAULT 0 NOT NULL, + ADD COLUMN last_sale_currency text DEFAULT '' NOT NULL, + ADD COLUMN last_sale_amount bigint DEFAULT 0 NOT NULL, + ADD CONSTRAINT unique_star_gift_owner_check CHECK ( + (owner_peer_type IN ('user','channel') AND owner_peer_id > 0 AND owner_address='') OR + (owner_peer_type IS NULL AND owner_peer_id IS NULL AND owner_address<>'') + ), + ADD CONSTRAINT unique_star_gift_released_by_check CHECK ( + (released_by_peer_type IS NULL AND released_by_peer_id IS NULL) OR + (released_by_peer_type IN ('user','channel') AND released_by_peer_id > 0) + ), + ADD CONSTRAINT unique_star_gift_theme_peer_check CHECK ( + (theme_peer_type IS NULL AND theme_peer_id IS NULL) OR + (theme_peer_type IN ('user','channel') AND theme_peer_id > 0) + ), + ADD CONSTRAINT unique_star_gift_host_peer_check CHECK ( + (host_peer_type IS NULL AND host_peer_id IS NULL) OR + (host_peer_type IN ('user','channel') AND host_peer_id > 0) + ), + ADD CONSTRAINT unique_star_gift_value_check CHECK ( + value_amount >= 0 AND value_usd_amount >= 0 AND offer_min_stars >= 0 AND + craft_chance_permille BETWEEN 0 AND 1000 AND last_sale_date >= 0 AND last_sale_amount >= 0 AND + ((value_currency='' AND value_amount=0) OR value_currency<>'') AND + ((last_sale_currency='' AND last_sale_amount=0 AND last_sale_date=0) OR + (last_sale_currency IN ('XTR','TON') AND last_sale_amount>0 AND last_sale_date>0)) AND + (NOT burned OR owner_address='') AND + ((owner_address='' AND gift_address='') OR (owner_address<>'' AND gift_address<>'')) + ); + +UPDATE public.unique_star_gifts u +SET original_owner_peer_type=p.owner_peer_type, original_owner_peer_id=p.owner_peer_id +FROM public.peer_star_gifts p WHERE p.id=u.source_saved_gift_id; + +ALTER TABLE public.unique_star_gifts + ALTER COLUMN original_owner_peer_type SET NOT NULL, + ALTER COLUMN original_owner_peer_id SET NOT NULL, + ADD CONSTRAINT unique_star_gift_original_owner_check CHECK ( + original_owner_peer_type IN ('user','channel') AND original_owner_peer_id>0 + ); + +CREATE TABLE public.star_gift_listings ( + unique_gift_id bigint PRIMARY KEY REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + seller_peer_type text NOT NULL, + seller_peer_id bigint NOT NULL, + currency text NOT NULL, + amount bigint NOT NULL, + listed_at integer NOT NULL, + updated_at integer NOT NULL, + version bigint DEFAULT 1 NOT NULL, + CONSTRAINT star_gift_listing_seller_check CHECK (seller_peer_type IN ('user','channel') AND seller_peer_id>0), + CONSTRAINT star_gift_listing_amount_check CHECK (currency IN ('XTR','TON') AND amount>0 AND listed_at>0 AND updated_at>=listed_at) +); +CREATE INDEX star_gift_listings_gift_price_idx ON public.star_gift_listings(currency, amount, unique_gift_id); +CREATE INDEX star_gift_listings_updated_idx ON public.star_gift_listings(updated_at DESC, unique_gift_id DESC); + +CREATE TABLE public.star_gift_sales ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + seller_peer_type text NOT NULL, + seller_peer_id bigint NOT NULL, + buyer_peer_type text NOT NULL, + buyer_peer_id bigint NOT NULL, + currency text NOT NULL, + amount bigint NOT NULL, + commission_amount bigint DEFAULT 0 NOT NULL, + sold_at integer NOT NULL, + command_key text NOT NULL, + CONSTRAINT star_gift_sales_command_uniq UNIQUE(command_key), + CONSTRAINT star_gift_sales_peer_check CHECK ( + seller_peer_type IN ('user','channel') AND seller_peer_id>0 AND + buyer_peer_type IN ('user','channel') AND buyer_peer_id>0), + CONSTRAINT star_gift_sales_amount_check CHECK ( + currency IN ('XTR','TON') AND amount>0 AND commission_amount>=0 AND commission_amount<=amount AND sold_at>0) +); +CREATE INDEX star_gift_sales_unique_date_idx ON public.star_gift_sales(unique_gift_id, sold_at DESC); + +CREATE TABLE public.star_gift_offers ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + buyer_user_id bigint NOT NULL, + owner_peer_type text NOT NULL, + owner_peer_id bigint NOT NULL, + unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + currency text NOT NULL, + amount bigint NOT NULL, + random_id bigint NOT NULL, + offer_msg_id integer DEFAULT 0 NOT NULL, + buyer_msg_id integer DEFAULT 0 NOT NULL, + status text DEFAULT 'pending' NOT NULL, + created_at integer NOT NULL, + expires_at integer NOT NULL, + resolved_at integer DEFAULT 0 NOT NULL, + balance_after bigint DEFAULT 0 NOT NULL, + expiry_notified boolean DEFAULT false NOT NULL, + CONSTRAINT star_gift_offer_random_uniq UNIQUE(buyer_user_id, random_id), + CONSTRAINT star_gift_offer_owner_msg_uniq UNIQUE(owner_peer_type, owner_peer_id, offer_msg_id), + CONSTRAINT star_gift_offer_peer_check CHECK (buyer_user_id>0 AND owner_peer_type IN ('user','channel') AND owner_peer_id>0), + CONSTRAINT star_gift_offer_amount_check CHECK (currency IN ('XTR','TON') AND amount>0), + CONSTRAINT star_gift_offer_status_check CHECK (status IN ('pending','accepted','declined','expired','cancelled')), + CONSTRAINT star_gift_offer_time_check CHECK (created_at>0 AND expires_at>created_at AND resolved_at>=0 AND + ((status='pending' AND resolved_at=0) OR (status<>'pending' AND resolved_at>=created_at))) +); +CREATE INDEX star_gift_offers_pending_expiry_idx ON public.star_gift_offers(expires_at, id) WHERE status='pending'; +CREATE INDEX star_gift_offers_unique_pending_idx ON public.star_gift_offers(unique_gift_id, id) WHERE status='pending'; + +CREATE TABLE public.star_gift_transfer_commands ( + actor_user_id bigint NOT NULL, + command_key text NOT NULL, + unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + from_peer_type text NOT NULL, + from_peer_id bigint NOT NULL, + to_peer_type text NOT NULL, + to_peer_id bigint NOT NULL, + charge_stars bigint DEFAULT 0 NOT NULL, + balance_after bigint DEFAULT 0 NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_transfer_commands_pkey PRIMARY KEY(actor_user_id, command_key), + CONSTRAINT star_gift_transfer_command_peer_check CHECK ( + actor_user_id>0 AND from_peer_type IN ('user','channel') AND from_peer_id>0 AND + to_peer_type IN ('user','channel') AND to_peer_id>0 AND charge_stars>=0 AND created_at>0) +); + +CREATE TABLE public.star_gift_purchase_commands ( + buyer_user_id bigint NOT NULL, + command_key text NOT NULL, + gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT, + recipient_peer_type text NOT NULL, + recipient_peer_id bigint NOT NULL, + saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT, + form_id bigint NOT NULL, + charge_stars bigint NOT NULL, + balance_after bigint NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_purchase_commands_pkey PRIMARY KEY(buyer_user_id,command_key), + CONSTRAINT star_gift_purchase_commands_form_uniq UNIQUE(buyer_user_id,form_id), + CONSTRAINT star_gift_purchase_command_shape_check CHECK ( + buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND + form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) +); + +CREATE TABLE public.star_gift_prepaid_upgrade_commands ( + payer_user_id bigint NOT NULL, + command_key text NOT NULL, + saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT, + form_id bigint NOT NULL, + charge_stars bigint NOT NULL, + balance_after bigint NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_prepaid_upgrade_commands_pkey PRIMARY KEY(payer_user_id, command_key), + CONSTRAINT star_gift_prepaid_upgrade_commands_form_uniq UNIQUE(payer_user_id, form_id), + CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK ( + payer_user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) +); + +CREATE TABLE public.star_gift_drop_details_commands ( + user_id bigint NOT NULL, + command_key text NOT NULL, + saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT, + unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + form_id bigint NOT NULL, + charge_stars bigint NOT NULL, + balance_after bigint NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_drop_details_commands_pkey PRIMARY KEY(user_id, command_key), + CONSTRAINT star_gift_drop_details_commands_form_uniq UNIQUE(user_id, form_id), + CONSTRAINT star_gift_drop_details_command_shape_check CHECK ( + user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) +); + +CREATE TABLE public.star_gift_craft_commands ( + user_id bigint NOT NULL, + command_key text NOT NULL, + input_unique_gift_ids bigint[] NOT NULL, + gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT, + success boolean NOT NULL, + result_unique_gift_id bigint REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + chance_permille integer NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_craft_commands_pkey PRIMARY KEY(user_id, command_key), + CONSTRAINT star_gift_craft_shape_check CHECK ( + user_id>0 AND cardinality(input_unique_gift_ids) BETWEEN 1 AND 4 AND + chance_permille BETWEEN 0 AND 1000 AND created_at>0 AND + ((success AND result_unique_gift_id IS NOT NULL) OR (NOT success AND result_unique_gift_id IS NULL))) +); + +CREATE TABLE public.star_gift_notification_settings ( + user_id bigint NOT NULL, + channel_id bigint NOT NULL, + enabled boolean DEFAULT true NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT star_gift_notification_settings_pkey PRIMARY KEY(user_id, channel_id), + CONSTRAINT star_gift_notification_settings_peer_check CHECK (user_id>0 AND channel_id>0) +); + +CREATE TABLE public.star_gift_withdrawal_requests ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + owner_user_id bigint NOT NULL, + provider text NOT NULL, + provider_request_id text NOT NULL, + url text NOT NULL, + status text DEFAULT 'pending' NOT NULL, + created_at integer NOT NULL, + expires_at integer NOT NULL, + completed_at integer DEFAULT 0 NOT NULL, + CONSTRAINT star_gift_withdrawal_request_unique UNIQUE(unique_gift_id), + CONSTRAINT star_gift_withdrawal_provider_request_uniq UNIQUE(provider, provider_request_id), + CONSTRAINT star_gift_withdrawal_shape_check CHECK ( + owner_user_id>0 AND provider<>'' AND provider_request_id<>'' AND url<>'' AND + status IN ('pending','completed','failed') AND created_at>0 AND expires_at>created_at AND completed_at>=0) +); + +CREATE TABLE public.star_gift_auctions ( + gift_id bigint PRIMARY KEY REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT, + slug text NOT NULL UNIQUE, + version integer DEFAULT 1 NOT NULL, + start_date integer NOT NULL, + end_date integer NOT NULL, + round_duration integer NOT NULL, + gifts_per_round integer NOT NULL, + total_rounds integer NOT NULL, + current_round integer DEFAULT 0 NOT NULL, + next_round_at integer NOT NULL, + last_gift_num integer DEFAULT 0 NOT NULL, + gifts_left integer NOT NULL, + min_bid_amount bigint NOT NULL, + status text DEFAULT 'pending' NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT star_gift_auction_shape_check CHECK ( + version>0 AND start_date>0 AND end_date>start_date AND round_duration>0 AND + gifts_per_round>0 AND total_rounds>0 AND current_round BETWEEN 0 AND total_rounds AND + next_round_at>=start_date AND last_gift_num>=0 AND gifts_left>=0 AND min_bid_amount>0 AND + status IN ('pending','active','completed','cancelled')) +); + +CREATE TABLE public.star_gift_auction_bids ( + gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT, + bidder_user_id bigint NOT NULL, + recipient_peer_type text NOT NULL, + recipient_peer_id bigint NOT NULL, + amount bigint NOT NULL, + bid_date integer NOT NULL, + hide_name boolean DEFAULT false NOT NULL, + message text DEFAULT '' NOT NULL, + returned boolean DEFAULT false NOT NULL, + acquired_count integer DEFAULT 0 NOT NULL, + active boolean DEFAULT true NOT NULL, + version bigint DEFAULT 1 NOT NULL, + CONSTRAINT star_gift_auction_bids_pkey PRIMARY KEY(gift_id, bidder_user_id), + CONSTRAINT star_gift_auction_bid_peer_check CHECK ( + bidder_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0), + CONSTRAINT star_gift_auction_bid_amount_check CHECK (amount>0 AND bid_date>0 AND acquired_count>=0 AND version>0) +); +CREATE INDEX star_gift_auction_bids_rank_idx ON public.star_gift_auction_bids(gift_id, amount DESC, bid_date, bidder_user_id) WHERE active; + +CREATE TABLE public.star_gift_auction_bid_payments ( + user_id bigint NOT NULL, + form_id bigint NOT NULL, + gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT, + bid_amount bigint NOT NULL, + balance_after bigint NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_auction_bid_payments_pkey PRIMARY KEY(user_id, form_id), + CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK ( + user_id>0 AND form_id>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0) +); + +CREATE TABLE public.star_gift_auction_acquired ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT, + bidder_user_id bigint NOT NULL, + recipient_peer_type text NOT NULL, + recipient_peer_id bigint NOT NULL, + saved_gift_id bigint REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT, + bid_amount bigint NOT NULL, + round integer NOT NULL, + pos integer NOT NULL, + gift_num integer, + acquired_at integer NOT NULL, + hide_name boolean DEFAULT false NOT NULL, + message text DEFAULT '' NOT NULL, + CONSTRAINT star_gift_auction_acquired_round_pos_uniq UNIQUE(gift_id, round, pos), + CONSTRAINT star_gift_auction_acquired_shape_check CHECK ( + bidder_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND + bid_amount>0 AND round>0 AND pos>0 AND acquired_at>0 AND (gift_num IS NULL OR gift_num>0)) +); +CREATE INDEX star_gift_auction_acquired_user_idx ON public.star_gift_auction_acquired(bidder_user_id, gift_id, id); + +CREATE TABLE public.ton_balances ( + user_id bigint PRIMARY KEY, + balance_nanoton bigint DEFAULT 0 NOT NULL, + granted boolean DEFAULT false NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT ton_balances_check CHECK (user_id>0 AND balance_nanoton>=0) +); + +CREATE TABLE public.ton_transactions ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + amount_nanoton bigint NOT NULL, + reason text NOT NULL, + peer_type text, + peer_id bigint, + gift_id bigint, + date integer NOT NULL, + CONSTRAINT ton_transaction_amount_check CHECK (user_id>0 AND amount_nanoton<>0 AND date>0), + CONSTRAINT ton_transaction_peer_check CHECK ( + (peer_type IS NULL AND peer_id IS NULL) OR (peer_type IN ('user','channel') AND peer_id>0)) +); +CREATE INDEX ton_transactions_user_idx ON public.ton_transactions(user_id, id DESC); + +CREATE FUNCTION public.telesrv_guard_star_gift_listing() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + gift_owner_type text; + gift_owner_id bigint; + gift_burned boolean; +BEGIN + SELECT owner_peer_type, owner_peer_id, burned + INTO gift_owner_type, gift_owner_id, gift_burned + FROM public.unique_star_gifts WHERE id=NEW.unique_gift_id FOR SHARE; + IF gift_burned OR gift_owner_type IS DISTINCT FROM NEW.seller_peer_type OR gift_owner_id IS DISTINCT FROM NEW.seller_peer_id THEN + RAISE EXCEPTION 'star gift listing owner/state mismatch'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER star_gift_listing_guard BEFORE INSERT OR UPDATE ON public.star_gift_listings + FOR EACH ROW EXECUTE FUNCTION public.telesrv_guard_star_gift_listing(); + +CREATE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + unique_id bigint; + gift_owner_type text; + gift_owner_id bigint; + gift_owner_address text; + gift_burned boolean; + saved_status text; + saved_owner_type text; + saved_owner_id bigint; +BEGIN + IF TG_TABLE_NAME = 'unique_star_gifts' THEN + unique_id := COALESCE(NEW.id, OLD.id); + ELSE + unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id); + END IF; + IF unique_id IS NULL THEN RETURN NULL; END IF; + SELECT owner_peer_type, owner_peer_id, owner_address, burned + INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned + FROM public.unique_star_gifts WHERE id=unique_id; + IF NOT FOUND THEN RETURN NULL; END IF; + SELECT lifecycle_status, owner_peer_type, owner_peer_id + INTO saved_status, saved_owner_type, saved_owner_id + FROM public.peer_star_gifts WHERE unique_gift_id=unique_id; + IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF; + IF gift_burned THEN + IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF; + ELSIF gift_owner_address <> '' THEN + IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF; + ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN + RAISE EXCEPTION 'unique star gift owner mismatch'; + END IF; + RETURN NULL; +END; +$$; +CREATE CONSTRAINT TRIGGER unique_star_gift_owner_guard + AFTER INSERT OR UPDATE ON public.unique_star_gifts DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION public.telesrv_check_unique_star_gift_owner(); +CREATE CONSTRAINT TRIGGER peer_unique_star_gift_owner_guard + AFTER INSERT OR UPDATE ON public.peer_star_gifts DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW WHEN (NEW.unique_gift_id IS NOT NULL) + EXECUTE FUNCTION public.telesrv_check_unique_star_gift_owner(); diff --git a/deploy/migrations/0096_star_gift_lifecycle_sweeper.down.sql b/deploy/migrations/0096_star_gift_lifecycle_sweeper.down.sql new file mode 100644 index 00000000..bb4bb699 --- /dev/null +++ b/deploy/migrations/0096_star_gift_lifecycle_sweeper.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS public.star_gift_auction_acquired_delivery_idx; +DROP INDEX IF EXISTS public.star_gift_auctions_due_idx; +DROP INDEX IF EXISTS public.star_gift_offers_resolution_outbox_idx; + +ALTER TABLE public.star_gift_offers + RENAME COLUMN resolution_notified TO expiry_notified; diff --git a/deploy/migrations/0096_star_gift_lifecycle_sweeper.up.sql b/deploy/migrations/0096_star_gift_lifecycle_sweeper.up.sql new file mode 100644 index 00000000..66b066bc --- /dev/null +++ b/deploy/migrations/0096_star_gift_lifecycle_sweeper.up.sql @@ -0,0 +1,16 @@ +-- Durable lifecycle sweep support. 0095 originally named this column after the +-- first use case (expiry); cancelled offers use the same outbox boundary. +ALTER TABLE public.star_gift_offers + RENAME COLUMN expiry_notified TO resolution_notified; + +CREATE INDEX star_gift_offers_resolution_outbox_idx + ON public.star_gift_offers(id) + WHERE status IN ('expired','cancelled') AND NOT resolution_notified; + +CREATE INDEX star_gift_auctions_due_idx + ON public.star_gift_auctions(status, next_round_at, gift_id) + WHERE status IN ('pending','active'); + +CREATE INDEX star_gift_auction_acquired_delivery_idx + ON public.star_gift_auction_acquired(gift_id, id) + WHERE saved_gift_id IS NULL; diff --git a/deploy/migrations/0097_star_gift_peer_stars_ledger.down.sql b/deploy/migrations/0097_star_gift_peer_stars_ledger.down.sql new file mode 100644 index 00000000..ff17f289 --- /dev/null +++ b/deploy/migrations/0097_star_gift_peer_stars_ledger.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS public.star_gift_conversions; +DROP TABLE IF EXISTS public.channel_stars_transactions; +DROP TABLE IF EXISTS public.channel_stars_balances; diff --git a/deploy/migrations/0097_star_gift_peer_stars_ledger.up.sql b/deploy/migrations/0097_star_gift_peer_stars_ledger.up.sql new file mode 100644 index 00000000..7e436b83 --- /dev/null +++ b/deploy/migrations/0097_star_gift_peer_stars_ledger.up.sql @@ -0,0 +1,40 @@ +-- Owner-scoped Stars revenue for channel Star Gifts. These are internal +-- telesrv ledgers only; no blockchain, wallet, TON node or Fragment endpoint is +-- contacted. Conversion is recorded as one aggregate terminal transition. + +CREATE TABLE public.channel_stars_balances ( + channel_id bigint PRIMARY KEY, + balance bigint DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_stars_balances_shape_check CHECK (channel_id>0 AND balance>=0) +); + +CREATE TABLE public.channel_stars_transactions ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + channel_id bigint NOT NULL, + actor_user_id bigint NOT NULL, + amount bigint NOT NULL, + reason text NOT NULL, + peer_type text DEFAULT '' NOT NULL, + peer_id bigint DEFAULT 0 NOT NULL, + gift_id bigint, + date integer NOT NULL, + CONSTRAINT channel_stars_transactions_shape_check CHECK ( + channel_id>0 AND actor_user_id>0 AND amount<>0 AND date>0 AND + ((peer_type='' AND peer_id=0) OR (peer_type IN ('user','channel') AND peer_id>0))) +); +CREATE INDEX channel_stars_transactions_channel_idx + ON public.channel_stars_transactions(channel_id,id DESC); + +CREATE TABLE public.star_gift_conversions ( + saved_gift_id bigint PRIMARY KEY REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT, + actor_user_id bigint NOT NULL, + owner_peer_type text NOT NULL, + owner_peer_id bigint NOT NULL, + amount bigint NOT NULL, + balance_after bigint NOT NULL, + converted_at integer NOT NULL, + CONSTRAINT star_gift_conversions_shape_check CHECK ( + actor_user_id>0 AND owner_peer_type IN ('user','channel') AND owner_peer_id>0 AND + amount>=0 AND balance_after>=0 AND converted_at>0) +); diff --git a/deploy/migrations/0098_star_gift_channel_ton_ledger.down.sql b/deploy/migrations/0098_star_gift_channel_ton_ledger.down.sql new file mode 100644 index 00000000..77204854 --- /dev/null +++ b/deploy/migrations/0098_star_gift_channel_ton_ledger.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS public.channel_ton_transactions; +DROP TABLE IF EXISTS public.channel_ton_balances; diff --git a/deploy/migrations/0098_star_gift_channel_ton_ledger.up.sql b/deploy/migrations/0098_star_gift_channel_ton_ledger.up.sql new file mode 100644 index 00000000..12496203 --- /dev/null +++ b/deploy/migrations/0098_star_gift_channel_ton_ledger.up.sql @@ -0,0 +1,25 @@ +-- TON-denominated channel marketplace proceeds remain a telesrv-local ledger. +-- No wallet, chain node, smart contract, Fragment or external RPC is involved. +CREATE TABLE public.channel_ton_balances ( + channel_id bigint PRIMARY KEY, + balance_nanoton bigint DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_ton_balances_shape_check CHECK (channel_id>0 AND balance_nanoton>=0) +); + +CREATE TABLE public.channel_ton_transactions ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + channel_id bigint NOT NULL, + actor_user_id bigint NOT NULL, + amount_nanoton bigint NOT NULL, + reason text NOT NULL, + peer_type text DEFAULT '' NOT NULL, + peer_id bigint DEFAULT 0 NOT NULL, + gift_id bigint, + date integer NOT NULL, + CONSTRAINT channel_ton_transactions_shape_check CHECK ( + channel_id>0 AND actor_user_id>0 AND amount_nanoton<>0 AND date>0 AND + ((peer_type='' AND peer_id=0) OR (peer_type IN ('user','channel') AND peer_id>0))) +); +CREATE INDEX channel_ton_transactions_channel_idx + ON public.channel_ton_transactions(channel_id,id DESC); diff --git a/deploy/migrations/0099_star_gift_signed_form_ids.down.sql b/deploy/migrations/0099_star_gift_signed_form_ids.down.sql new file mode 100644 index 00000000..d34f2c3d --- /dev/null +++ b/deploy/migrations/0099_star_gift_signed_form_ids.down.sql @@ -0,0 +1,20 @@ +ALTER TABLE public.star_gift_purchase_commands + DROP CONSTRAINT star_gift_purchase_command_shape_check, + ADD CONSTRAINT star_gift_purchase_command_shape_check CHECK ( + buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND + form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID; + +ALTER TABLE public.star_gift_prepaid_upgrade_commands + DROP CONSTRAINT star_gift_prepaid_upgrade_command_shape_check, + ADD CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK ( + payer_user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID; + +ALTER TABLE public.star_gift_drop_details_commands + DROP CONSTRAINT star_gift_drop_details_command_shape_check, + ADD CONSTRAINT star_gift_drop_details_command_shape_check CHECK ( + user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID; + +ALTER TABLE public.star_gift_auction_bid_payments + DROP CONSTRAINT star_gift_auction_bid_payment_shape_check, + ADD CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK ( + user_id>0 AND form_id>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0) NOT VALID; diff --git a/deploy/migrations/0099_star_gift_signed_form_ids.up.sql b/deploy/migrations/0099_star_gift_signed_form_ids.up.sql new file mode 100644 index 00000000..3a160bf9 --- /dev/null +++ b/deploy/migrations/0099_star_gift_signed_form_ids.up.sql @@ -0,0 +1,20 @@ +ALTER TABLE public.star_gift_purchase_commands + DROP CONSTRAINT star_gift_purchase_command_shape_check, + ADD CONSTRAINT star_gift_purchase_command_shape_check CHECK ( + buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND + form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0); + +ALTER TABLE public.star_gift_prepaid_upgrade_commands + DROP CONSTRAINT star_gift_prepaid_upgrade_command_shape_check, + ADD CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK ( + payer_user_id>0 AND form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0); + +ALTER TABLE public.star_gift_drop_details_commands + DROP CONSTRAINT star_gift_drop_details_command_shape_check, + ADD CONSTRAINT star_gift_drop_details_command_shape_check CHECK ( + user_id>0 AND form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0); + +ALTER TABLE public.star_gift_auction_bid_payments + DROP CONSTRAINT star_gift_auction_bid_payment_shape_check, + ADD CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK ( + user_id>0 AND form_id<>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0); diff --git a/deploy/migrations/0100_star_gift_upgrade_semantics.down.sql b/deploy/migrations/0100_star_gift_upgrade_semantics.down.sql new file mode 100644 index 00000000..ed5c6b89 --- /dev/null +++ b/deploy/migrations/0100_star_gift_upgrade_semantics.down.sql @@ -0,0 +1,35 @@ +UPDATE public.message_boxes +SET media = jsonb_set(media, '{service_action,star_gift,upgrade_stars}', media #> '{service_action,star_gift,upgrade_price_stars}', true) + #- '{service_action,star_gift,upgrade_price_stars}' +WHERE media #>> '{service_action,kind}' = 'star_gift' + AND media #> '{service_action,star_gift,upgrade_price_stars}' IS NOT NULL; + +UPDATE public.channel_messages +SET action = jsonb_set(action, '{StarGift,upgrade_stars}', action #> '{StarGift,upgrade_price_stars}', true) + #- '{StarGift,upgrade_price_stars}' +WHERE action #>> '{Type}' = 'star_gift' + AND action #> '{StarGift,upgrade_price_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET message = jsonb_set(message, '{Action,StarGift,upgrade_stars}', message #> '{Action,StarGift,upgrade_price_stars}', true) + #- '{Action,StarGift,upgrade_price_stars}' +WHERE message #>> '{Action,Type}' = 'star_gift' + AND message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET prev_message = jsonb_set(prev_message, '{Action,StarGift,upgrade_stars}', prev_message #> '{Action,StarGift,upgrade_price_stars}', true) + #- '{Action,StarGift,upgrade_price_stars}' +WHERE prev_message #>> '{Action,Type}' = 'star_gift' + AND prev_message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET new_message = jsonb_set(new_message, '{Action,StarGift,upgrade_stars}', new_message #> '{Action,StarGift,upgrade_price_stars}', true) + #- '{Action,StarGift,upgrade_price_stars}' +WHERE new_message #>> '{Action,Type}' = 'star_gift' + AND new_message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL; + +ALTER TABLE public.star_gift_upgrade_commands + DROP CONSTRAINT IF EXISTS star_gift_upgrade_command_replay_shape_check, + DROP COLUMN IF EXISTS keep_original_details, + DROP COLUMN IF EXISTS require_prepaid, + DROP COLUMN IF EXISTS charge_stars; diff --git a/deploy/migrations/0100_star_gift_upgrade_semantics.up.sql b/deploy/migrations/0100_star_gift_upgrade_semantics.up.sql new file mode 100644 index 00000000..0f6d0fde --- /dev/null +++ b/deploy/migrations/0100_star_gift_upgrade_semantics.up.sql @@ -0,0 +1,79 @@ +-- Split the two TL fields that TDesktop consumes differently: +-- StarGift.upgrade_stars = current paid-upgrade price +-- messageActionStarGift.upgrade_stars = amount already prepaid by sender +-- Also persist the immutable command envelope required to replay an upgrade +-- after the saved gift has entered its unique terminal state. + +ALTER TABLE public.star_gift_upgrade_commands + ADD COLUMN charge_stars bigint NOT NULL DEFAULT 0, + ADD COLUMN require_prepaid boolean NOT NULL DEFAULT false, + ADD COLUMN keep_original_details boolean NOT NULL DEFAULT false; + +UPDATE public.star_gift_upgrade_commands c +SET charge_stars = CASE WHEN c.form_id = 0 THEN 0 ELSE r.upgrade_stars END, + require_prepaid = (c.form_id = 0), + keep_original_details = u.keep_original_details +FROM public.unique_star_gifts u +JOIN public.star_gift_collectible_revisions r ON r.id = u.collectible_revision_id +WHERE u.id = c.unique_gift_id; + +ALTER TABLE public.star_gift_upgrade_commands + ADD CONSTRAINT star_gift_upgrade_command_replay_shape_check CHECK ( + (require_prepaid AND form_id = 0 AND charge_stars = 0) + OR + (NOT require_prepaid AND form_id <> 0 AND charge_stars > 0) + ); + +-- Private message boxes are the canonical durable snapshots used by history, +-- live outbox delivery and updates.getDifference. +UPDATE public.message_boxes +SET media = CASE + WHEN COALESCE((media #>> '{service_action,star_gift,prepaid_upgrade}')::boolean, false) + THEN jsonb_set(media, '{service_action,star_gift,upgrade_price_stars}', media #> '{service_action,star_gift,upgrade_stars}', true) + ELSE jsonb_set(media, '{service_action,star_gift,upgrade_price_stars}', media #> '{service_action,star_gift,upgrade_stars}', true) + #- '{service_action,star_gift,upgrade_stars}' +END +WHERE media #>> '{service_action,kind}' = 'star_gift' + AND media #> '{service_action,star_gift,upgrade_stars}' IS NOT NULL; + +-- Channel service-message and Recent Actions snapshots use exported Go field +-- names for their outer objects and the same snake_case StarGift payload. +UPDATE public.channel_messages +SET action = CASE + WHEN COALESCE((action #>> '{StarGift,prepaid_upgrade}')::boolean, false) + THEN jsonb_set(action, '{StarGift,upgrade_price_stars}', action #> '{StarGift,upgrade_stars}', true) + ELSE jsonb_set(action, '{StarGift,upgrade_price_stars}', action #> '{StarGift,upgrade_stars}', true) + #- '{StarGift,upgrade_stars}' +END +WHERE action #>> '{Type}' = 'star_gift' + AND action #> '{StarGift,upgrade_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET message = CASE + WHEN COALESCE((message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false) + THEN jsonb_set(message, '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}', true) + ELSE jsonb_set(message, '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}', true) + #- '{Action,StarGift,upgrade_stars}' +END +WHERE message #>> '{Action,Type}' = 'star_gift' + AND message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET prev_message = CASE + WHEN COALESCE((prev_message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false) + THEN jsonb_set(prev_message, '{Action,StarGift,upgrade_price_stars}', prev_message #> '{Action,StarGift,upgrade_stars}', true) + ELSE jsonb_set(prev_message, '{Action,StarGift,upgrade_price_stars}', prev_message #> '{Action,StarGift,upgrade_stars}', true) + #- '{Action,StarGift,upgrade_stars}' +END +WHERE prev_message #>> '{Action,Type}' = 'star_gift' + AND prev_message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET new_message = CASE + WHEN COALESCE((new_message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false) + THEN jsonb_set(new_message, '{Action,StarGift,upgrade_price_stars}', new_message #> '{Action,StarGift,upgrade_stars}', true) + ELSE jsonb_set(new_message, '{Action,StarGift,upgrade_price_stars}', new_message #> '{Action,StarGift,upgrade_stars}', true) + #- '{Action,StarGift,upgrade_stars}' +END +WHERE new_message #>> '{Action,Type}' = 'star_gift' + AND new_message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL; diff --git a/deploy/migrations/0101_star_gift_upgrade_projection_repair.down.sql b/deploy/migrations/0101_star_gift_upgrade_projection_repair.down.sql new file mode 100644 index 00000000..94b14285 --- /dev/null +++ b/deploy/migrations/0101_star_gift_upgrade_projection_repair.down.sql @@ -0,0 +1,3 @@ +-- The up migration emits user-visible durable update facts. Rewinding pts or +-- deleting events that may already have been delivered would create a hole in +-- updates.getDifference, so rollback intentionally preserves those facts. diff --git a/deploy/migrations/0101_star_gift_upgrade_projection_repair.up.sql b/deploy/migrations/0101_star_gift_upgrade_projection_repair.up.sql new file mode 100644 index 00000000..54409686 --- /dev/null +++ b/deploy/migrations/0101_star_gift_upgrade_projection_repair.up.sql @@ -0,0 +1,74 @@ +-- Migration 0100 corrected the durable Star Gift service-message JSON, but a +-- TDesktop that had already cached the old message would otherwise keep using +-- the conflated outer upgrade_stars field. Publish one durable edit event for +-- every private message box whose paid-upgrade price was split by 0100. +-- +-- This is deliberately a pts event, not a cache-only notification: history, +-- updates.getDifference and online outbox delivery must all expose the same +-- corrected message snapshot. The migration transaction keeps the message +-- pts, user watermark, durable event and dispatch task atomic. +DO $$ +DECLARE + gift_box record; + next_pts integer; + event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer; +BEGIN + FOR gift_box IN + SELECT owner_user_id, box_id, peer_type, peer_id + FROM public.message_boxes + WHERE media #>> '{service_action,kind}' = 'star_gift' + AND jsonb_typeof(media #> '{service_action,star_gift,upgrade_price_stars}') = 'number' + AND (media #>> '{service_action,star_gift,upgrade_price_stars}')::bigint > 0 + ORDER BY owner_user_id, box_id + LOOP + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (gift_box.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = gift_box.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET pts = next_pts + WHERE owner_user_id = gift_box.owner_user_id + AND box_id = gift_box.box_id; + + INSERT INTO public.user_update_events ( + user_id, + pts, + pts_count, + date, + event_type, + message_box_id, + peer_type, + peer_id + ) VALUES ( + gift_box.owner_user_id, + next_pts, + 1, + event_date, + 'edit_message', + gift_box.box_id, + gift_box.peer_type, + gift_box.peer_id + ); + + INSERT INTO public.dispatch_outbox ( + target_user_id, + pts, + event_type, + exclude_auth_key_id, + exclude_session_id + ) VALUES ( + gift_box.owner_user_id, + next_pts, + 'edit_message', + 0, + 0 + ); + END LOOP; +END +$$; diff --git a/deploy/migrations/0102_star_gift_purchase_forms.down.sql b/deploy/migrations/0102_star_gift_purchase_forms.down.sql new file mode 100644 index 00000000..7bc21a54 --- /dev/null +++ b/deploy/migrations/0102_star_gift_purchase_forms.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.star_gift_purchase_forms; diff --git a/deploy/migrations/0102_star_gift_purchase_forms.up.sql b/deploy/migrations/0102_star_gift_purchase_forms.up.sql new file mode 100644 index 00000000..d0dfa0c2 --- /dev/null +++ b/deploy/migrations/0102_star_gift_purchase_forms.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE public.star_gift_purchase_forms ( + buyer_user_id bigint NOT NULL, + form_id bigint NOT NULL, + gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT, + revision_id bigint NOT NULL REFERENCES public.star_gift_catalog_revisions(id) ON DELETE RESTRICT, + recipient_peer_type text NOT NULL, + recipient_peer_id bigint NOT NULL, + include_upgrade boolean DEFAULT false NOT NULL, + hide_name boolean DEFAULT false NOT NULL, + message text DEFAULT '' NOT NULL, + charge_stars bigint NOT NULL, + issued_at integer NOT NULL, + expires_at integer NOT NULL, + CONSTRAINT star_gift_purchase_forms_pkey PRIMARY KEY (buyer_user_id, form_id), + CONSTRAINT star_gift_purchase_form_shape_check CHECK ( + buyer_user_id > 0 AND form_id <> 0 AND gift_id > 0 AND revision_id > 0 AND + recipient_peer_type IN ('user', 'channel') AND recipient_peer_id > 0 AND + charge_stars > 0 AND issued_at > 0 AND expires_at = issued_at + 600 AND + char_length(message) <= 128) +); + +CREATE INDEX star_gift_purchase_forms_expiry_idx + ON public.star_gift_purchase_forms (expires_at, buyer_user_id, form_id); diff --git a/deploy/migrations/0103_star_gift_upgrade_message_links.down.sql b/deploy/migrations/0103_star_gift_upgrade_message_links.down.sql new file mode 100644 index 00000000..6bba69b1 --- /dev/null +++ b/deploy/migrations/0103_star_gift_upgrade_message_links.down.sql @@ -0,0 +1,5 @@ +-- Projection repair events are durable account history and are intentionally +-- not erased on rollback. Only remove the replay receipt column. +ALTER TABLE public.star_gift_upgrade_commands + DROP CONSTRAINT IF EXISTS star_gift_upgrade_command_source_edit_pts_check, + DROP COLUMN IF EXISTS source_edit_pts; diff --git a/deploy/migrations/0103_star_gift_upgrade_message_links.up.sql b/deploy/migrations/0103_star_gift_upgrade_message_links.up.sql new file mode 100644 index 00000000..3cc23634 --- /dev/null +++ b/deploy/migrations/0103_star_gift_upgrade_message_links.up.sql @@ -0,0 +1,191 @@ +-- A user-owned upgraded gift has one stable protocol identity: the original +-- gift service-message id. The unique service message points back to it via +-- saved_id, while the original message points forward to the box-local unique +-- service message via upgrade_msg_id. Both projections must be durable pts +-- edits so history, live delivery and updates.getDifference agree. + +ALTER TABLE public.star_gift_upgrade_commands + ADD COLUMN source_edit_pts integer DEFAULT 0 NOT NULL, + ADD CONSTRAINT star_gift_upgrade_command_source_edit_pts_check CHECK (source_edit_pts >= 0); + +DO $$ +DECLARE + gift record; + source_root record; + upgrade_root record; + pair record; + next_pts integer; + event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer; + repaired_source_media jsonb; + repaired_unique_media jsonb; + private_media jsonb; +BEGIN + FOR gift IN + SELECT id, owner_peer_id, msg_id, upgrade_msg_id + FROM public.peer_star_gifts + WHERE owner_peer_type = 'user' + AND unique_gift_id IS NOT NULL + AND lifecycle_status = 'active' + AND msg_id > 0 + AND upgrade_msg_id > 0 + ORDER BY id + LOOP + SELECT private_message_id, message_sender_id + INTO STRICT source_root + FROM public.message_boxes + WHERE owner_user_id = gift.owner_peer_id + AND box_id = gift.msg_id + AND NOT deleted; + + SELECT private_message_id, message_sender_id + INTO STRICT upgrade_root + FROM public.message_boxes + WHERE owner_user_id = gift.owner_peer_id + AND box_id = gift.upgrade_msg_id + AND NOT deleted; + + FOR pair IN + SELECT source_box.owner_user_id, + source_box.box_id AS source_box_id, + source_box.peer_type AS source_peer_type, + source_box.peer_id AS source_peer_id, + source_box.media AS source_media, + unique_box.box_id AS unique_box_id, + unique_box.peer_type AS unique_peer_type, + unique_box.peer_id AS unique_peer_id, + unique_box.media AS unique_media + FROM public.message_boxes source_box + JOIN public.message_boxes unique_box + ON unique_box.owner_user_id = source_box.owner_user_id + AND unique_box.message_sender_id = upgrade_root.message_sender_id + AND unique_box.private_message_id = upgrade_root.private_message_id + AND NOT unique_box.deleted + WHERE source_box.message_sender_id = source_root.message_sender_id + AND source_box.private_message_id = source_root.private_message_id + AND NOT source_box.deleted + ORDER BY source_box.owner_user_id + LOOP + IF pair.source_media #>> '{service_action,kind}' <> 'star_gift' THEN + RAISE EXCEPTION 'saved gift % source box % has invalid service action', gift.id, pair.source_box_id; + END IF; + IF pair.unique_media #>> '{service_action,kind}' <> 'star_gift_unique' THEN + RAISE EXCEPTION 'saved gift % unique box % has invalid service action', gift.id, pair.unique_box_id; + END IF; + + repaired_source_media := jsonb_set( + pair.source_media, + '{service_action,star_gift,upgrade_msg_id}', + to_jsonb(pair.unique_box_id::bigint), + true + ) #- '{service_action,star_gift,can_upgrade}'; + repaired_unique_media := jsonb_set( + pair.unique_media, + '{service_action,star_gift_unique,saved_id}', + to_jsonb(pair.source_box_id::bigint), + true + ); + + IF pair.source_media IS DISTINCT FROM repaired_source_media THEN + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (pair.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = pair.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET media = repaired_source_media, + pts = next_pts + WHERE owner_user_id = pair.owner_user_id + AND box_id = pair.source_box_id + AND NOT deleted; + + INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + message_box_id, peer_type, peer_id + ) VALUES ( + pair.owner_user_id, next_pts, 1, event_date, 'edit_message', + pair.source_box_id, pair.source_peer_type, pair.source_peer_id + ); + + INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, + exclude_auth_key_id, exclude_session_id + ) VALUES (pair.owner_user_id, next_pts, 'edit_message', 0, 0); + + IF pair.owner_user_id = gift.owner_peer_id THEN + UPDATE public.star_gift_upgrade_commands + SET source_edit_pts = next_pts + WHERE source_saved_gift_id = gift.id; + END IF; + END IF; + + IF pair.unique_media IS DISTINCT FROM repaired_unique_media THEN + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (pair.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = pair.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET media = repaired_unique_media, + pts = next_pts + WHERE owner_user_id = pair.owner_user_id + AND box_id = pair.unique_box_id + AND NOT deleted; + + INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + message_box_id, peer_type, peer_id + ) VALUES ( + pair.owner_user_id, next_pts, 1, event_date, 'edit_message', + pair.unique_box_id, pair.unique_peer_type, pair.unique_peer_id + ); + + INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, + exclude_auth_key_id, exclude_session_id + ) VALUES (pair.owner_user_id, next_pts, 'edit_message', 0, 0); + END IF; + END LOOP; + + SELECT media INTO STRICT private_media + FROM public.message_boxes + WHERE message_sender_id = source_root.message_sender_id + AND private_message_id = source_root.private_message_id + AND NOT deleted + ORDER BY (owner_user_id = message_sender_id) DESC, owner_user_id + LIMIT 1; + UPDATE public.private_messages + SET media = private_media + WHERE sender_user_id = source_root.message_sender_id + AND id = source_root.private_message_id; + + SELECT media INTO STRICT private_media + FROM public.message_boxes + WHERE message_sender_id = upgrade_root.message_sender_id + AND private_message_id = upgrade_root.private_message_id + AND NOT deleted + ORDER BY (owner_user_id = message_sender_id) DESC, owner_user_id + LIMIT 1; + UPDATE public.private_messages + SET media = private_media + WHERE sender_user_id = upgrade_root.message_sender_id + AND id = upgrade_root.private_message_id; + + IF EXISTS ( + SELECT 1 FROM public.star_gift_upgrade_commands + WHERE source_saved_gift_id = gift.id AND source_edit_pts <= 0 + ) THEN + RAISE EXCEPTION 'saved gift % is missing its owner source edit receipt', gift.id; + END IF; + END LOOP; +END +$$; diff --git a/deploy/migrations/0104_star_gift_craft_capability.down.sql b/deploy/migrations/0104_star_gift_craft_capability.down.sql new file mode 100644 index 00000000..186f2452 --- /dev/null +++ b/deploy/migrations/0104_star_gift_craft_capability.down.sql @@ -0,0 +1,4 @@ +-- This migration repairs invalid persisted capabilities and intentionally does +-- not restore them on downgrade: advertising Craft without an official crafted +-- model would reintroduce a user-visible operation that can never succeed. +SELECT 1; diff --git a/deploy/migrations/0104_star_gift_craft_capability.up.sql b/deploy/migrations/0104_star_gift_craft_capability.up.sql new file mode 100644 index 00000000..bc453b55 --- /dev/null +++ b/deploy/migrations/0104_star_gift_craft_capability.up.sql @@ -0,0 +1,109 @@ +-- Craft is a protocol capability, not a generic property of every collectible. +-- A non-zero craft_chance_permille is valid only when the immutable official +-- collectible revision contains at least one crafted model. Earlier versions +-- advertised the default chance for every upgrade, including official sets +-- such as Fresh Socks whose snapshot has no crafted model at all. +-- +-- Repair the aggregate and every durable private-message projection together. +-- Each visible message edit advances pts and is recoverable through both live +-- outbox delivery and updates.getDifference. +DO $$ +DECLARE + gift_box record; + next_pts integer; + event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer; + repaired_media jsonb; +BEGIN + UPDATE public.unique_star_gifts unique_gift + SET craft_chance_permille = 0, + updated_at = now() + WHERE unique_gift.craft_chance_permille > 0 + AND NOT EXISTS ( + SELECT 1 + FROM public.star_gift_collectible_models model + WHERE model.collectible_revision_id = unique_gift.collectible_revision_id + AND model.crafted + ); + + UPDATE public.peer_star_gifts saved_gift + SET can_craft_at = 0 + FROM public.unique_star_gifts unique_gift + WHERE unique_gift.id = saved_gift.unique_gift_id + AND unique_gift.craft_chance_permille = 0 + AND NOT EXISTS ( + SELECT 1 + FROM public.star_gift_collectible_models model + WHERE model.collectible_revision_id = unique_gift.collectible_revision_id + AND model.crafted + ); + + FOR gift_box IN + SELECT box.owner_user_id, + box.box_id, + box.peer_type, + box.peer_id, + box.message_sender_id, + box.private_message_id, + box.media + FROM public.message_boxes box + JOIN public.unique_star_gifts unique_gift + ON unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint + WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND jsonb_typeof(box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}') = 'number' + AND (box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::integer > 0 + AND NOT EXISTS ( + SELECT 1 + FROM public.star_gift_collectible_models model + WHERE model.collectible_revision_id = unique_gift.collectible_revision_id + AND model.crafted + ) + ORDER BY box.owner_user_id, box.box_id + LOOP + repaired_media := gift_box.media + #- '{service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{service_action,star_gift_unique,can_craft_at}'; + + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (gift_box.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = gift_box.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET media = repaired_media, + pts = next_pts + WHERE owner_user_id = gift_box.owner_user_id + AND box_id = gift_box.box_id + AND NOT deleted; + + UPDATE public.private_messages + SET media = media + #- '{service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{service_action,star_gift_unique,can_craft_at}', + sender_snapshot = sender_snapshot + #- '{message,Media,service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{message,Media,service_action,star_gift_unique,can_craft_at}' + WHERE sender_user_id = gift_box.message_sender_id + AND id = gift_box.private_message_id; + + INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + message_box_id, peer_type, peer_id + ) VALUES ( + gift_box.owner_user_id, next_pts, 1, event_date, 'edit_message', + gift_box.box_id, gift_box.peer_type, gift_box.peer_id + ); + + INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, + exclude_auth_key_id, exclude_session_id + ) VALUES ( + gift_box.owner_user_id, next_pts, 'edit_message', 0, 0 + ); + END LOOP; +END +$$; diff --git a/deploy/migrations/0105_star_gift_craft_projection.down.sql b/deploy/migrations/0105_star_gift_craft_projection.down.sql new file mode 100644 index 00000000..bdbe8d3b --- /dev/null +++ b/deploy/migrations/0105_star_gift_craft_projection.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE public.star_gift_craft_commands + DROP CONSTRAINT IF EXISTS star_gift_craft_source_edit_shape_check, + DROP COLUMN IF EXISTS source_edit_pts; + +-- Burned/crafted lifecycle facts and emitted edit events are authoritative +-- business history. Downgrade intentionally does not resurrect consumed gifts. +SELECT 1; diff --git a/deploy/migrations/0105_star_gift_craft_projection.up.sql b/deploy/migrations/0105_star_gift_craft_projection.up.sql new file mode 100644 index 00000000..f256c6ff --- /dev/null +++ b/deploy/migrations/0105_star_gift_craft_projection.up.sql @@ -0,0 +1,195 @@ +-- Craft outcomes consume their inputs permanently. Keep the aggregate, every +-- visible messageActionStarGiftUnique snapshot, pts/outbox delivery and command +-- replay receipt in one state model. +ALTER TABLE public.star_gift_craft_commands + ADD COLUMN source_edit_pts integer[] DEFAULT ARRAY[]::integer[] NOT NULL; + +DO $$ +DECLARE + gift_box record; + next_pts integer; + event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer; + repaired_media jsonb; +BEGIN + -- A successful legacy command would require reconstructing the crafted + -- model snapshot, not merely flipping flags. Fail fast instead of silently + -- fabricating that projection; the development database must be rebuilt or + -- repaired explicitly if such a row ever exists. + IF EXISTS (SELECT 1 FROM public.star_gift_craft_commands WHERE success) THEN + RAISE EXCEPTION 'cannot migrate legacy successful craft command without an exact crafted message projection'; + END IF; + + -- upgrade_msg_id means the current owner's unique service-message + -- projection, not permanently the first upgrade message. Ownership moves + -- replace msg_id with the new transfer/resale/offer message; repair rows + -- written before that invariant was enforced. + UPDATE public.peer_star_gifts saved_gift + SET upgrade_msg_id = saved_gift.msg_id + WHERE saved_gift.owner_peer_type = 'user' + AND saved_gift.unique_gift_id IS NOT NULL + AND saved_gift.msg_id > 0 + AND EXISTS ( + SELECT 1 + FROM public.message_boxes box + WHERE box.owner_user_id = saved_gift.owner_peer_id + AND box.box_id = saved_gift.msg_id + AND NOT box.deleted + AND box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint = saved_gift.unique_gift_id + ); + + UPDATE public.peer_star_gifts + SET upgrade_msg_id = 0 + WHERE owner_peer_type = 'channel' + AND unique_gift_id IS NOT NULL + AND upgrade_msg_id <> 0; + + UPDATE public.unique_star_gifts + SET burned = true, + craft_chance_permille = 0, + offer_min_stars = 0, + updated_at = now() + WHERE burned; + + UPDATE public.peer_star_gifts + SET lifecycle_status = 'burned', + unsaved = true, + pinned_order = 0, + transfer_stars = 0, + can_export_at = 0, + can_transfer_at = 0, + can_resell_at = 0, + drop_original_details_stars = 0, + can_craft_at = 0 + WHERE lifecycle_status = 'burned'; + + FOR gift_box IN + SELECT box.owner_user_id, + box.box_id, + box.peer_type, + box.peer_id, + box.message_sender_id, + box.private_message_id, + box.media + FROM public.message_boxes box + JOIN public.unique_star_gifts unique_gift + ON unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint + WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND unique_gift.burned + AND ( + COALESCE((box.media #>> '{service_action,star_gift_unique,gift,Burned}')::boolean, false) = false + OR COALESCE((box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::integer, 0) <> 0 + OR box.media #> '{service_action,star_gift_unique,saved}' IS NOT NULL + OR box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL + ) + ORDER BY box.owner_user_id, box.box_id + LOOP + repaired_media := jsonb_set( + jsonb_set( + gift_box.media + #- '{service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{service_action,star_gift_unique,saved}' + #- '{service_action,star_gift_unique,can_export_at}' + #- '{service_action,star_gift_unique,transfer_stars}' + #- '{service_action,star_gift_unique,resale_amount}' + #- '{service_action,star_gift_unique,can_transfer_at}' + #- '{service_action,star_gift_unique,can_resell_at}' + #- '{service_action,star_gift_unique,drop_original_details_stars}' + #- '{service_action,star_gift_unique,can_craft_at}', + '{service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true), + '{service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true); + + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (gift_box.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = gift_box.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET media = repaired_media, + pts = next_pts + WHERE owner_user_id = gift_box.owner_user_id + AND box_id = gift_box.box_id + AND NOT deleted; + + UPDATE public.private_messages + SET media = jsonb_set( + jsonb_set( + media + #- '{service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{service_action,star_gift_unique,saved}' + #- '{service_action,star_gift_unique,can_export_at}' + #- '{service_action,star_gift_unique,transfer_stars}' + #- '{service_action,star_gift_unique,resale_amount}' + #- '{service_action,star_gift_unique,can_transfer_at}' + #- '{service_action,star_gift_unique,can_resell_at}' + #- '{service_action,star_gift_unique,drop_original_details_stars}' + #- '{service_action,star_gift_unique,can_craft_at}', + '{service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true), + '{service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true), + sender_snapshot = jsonb_set( + jsonb_set( + sender_snapshot + #- '{message,Media,service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{message,Media,service_action,star_gift_unique,saved}' + #- '{message,Media,service_action,star_gift_unique,can_export_at}' + #- '{message,Media,service_action,star_gift_unique,transfer_stars}' + #- '{message,Media,service_action,star_gift_unique,resale_amount}' + #- '{message,Media,service_action,star_gift_unique,can_transfer_at}' + #- '{message,Media,service_action,star_gift_unique,can_resell_at}' + #- '{message,Media,service_action,star_gift_unique,drop_original_details_stars}' + #- '{message,Media,service_action,star_gift_unique,can_craft_at}', + '{message,Media,service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true), + '{message,Media,service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true) + WHERE sender_user_id = gift_box.message_sender_id + AND id = gift_box.private_message_id; + + INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + message_box_id, peer_type, peer_id + ) VALUES ( + gift_box.owner_user_id, next_pts, 1, event_date, 'edit_message', + gift_box.box_id, gift_box.peer_type, gift_box.peer_id + ); + + INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, + exclude_auth_key_id, exclude_session_id + ) VALUES ( + gift_box.owner_user_id, next_pts, 'edit_message', 0, 0 + ); + END LOOP; + + UPDATE public.star_gift_craft_commands command + SET source_edit_pts = ( + SELECT array_agg(box.pts ORDER BY input.ordinality)::integer[] AS pts + FROM unnest(command.input_unique_gift_ids) WITH ORDINALITY AS input(unique_gift_id, ordinality) + JOIN public.unique_star_gifts unique_gift ON unique_gift.id = input.unique_gift_id + JOIN public.peer_star_gifts saved_gift ON saved_gift.id = unique_gift.source_saved_gift_id + JOIN public.message_boxes box + ON box.owner_user_id = command.user_id + AND box.box_id = saved_gift.upgrade_msg_id + AND NOT box.deleted + ) + WHERE NOT command.success; + + IF EXISTS ( + SELECT 1 + FROM public.star_gift_craft_commands + WHERE cardinality(source_edit_pts) <> cardinality(input_unique_gift_ids) + OR array_position(source_edit_pts, 0) IS NOT NULL + ) THEN + RAISE EXCEPTION 'craft command is missing an exact source message edit receipt'; + END IF; +END +$$; + +ALTER TABLE public.star_gift_craft_commands + ADD CONSTRAINT star_gift_craft_source_edit_shape_check CHECK ( + cardinality(source_edit_pts) = cardinality(input_unique_gift_ids) + AND array_position(source_edit_pts, 0) IS NULL + ); diff --git a/internal/admin/service.go b/internal/admin/service.go index 8ee6b89f..386f1591 100644 --- a/internal/admin/service.go +++ b/internal/admin/service.go @@ -4,15 +4,18 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "fmt" "math" "net/url" "reflect" "sort" + "strconv" "strings" "time" "telesrv/internal/domain" + "telesrv/internal/officialgifts" ) const ( @@ -25,6 +28,7 @@ const ( ActionDeletePrivateMessages = "messages.delete_private_messages" ActionDeletePrivateHistory = "messages.delete_private_history" ActionImportStarGift = "gifts.import" + ActionImportOfficialStarGift = "gifts.official.import" ActionPublishGiftCollectibles = "gifts.collectibles.publish" ActionSetStarGiftEnabled = "gifts.set_enabled" ActionSetStarGiftSortOrder = "gifts.set_sort_order" @@ -94,7 +98,9 @@ type MessagesService interface { type GiftsService interface { PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) + PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, 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) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) @@ -103,6 +109,11 @@ 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) +} + type Dependencies struct { Commands CommandRepository Restrictions RestrictionStore @@ -116,6 +127,7 @@ type Dependencies struct { ChannelNotifier ChannelNotifier Messages MessagesService Gifts GiftsService + OfficialGifts OfficialGiftsSource Now func() time.Time } @@ -132,6 +144,7 @@ type Service struct { channelNotifier ChannelNotifier messages MessagesService gifts GiftsService + officialGifts OfficialGiftsSource now func() time.Time } @@ -177,6 +190,9 @@ 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.Now != nil { s.now = deps.Now } @@ -219,6 +235,23 @@ type ImportStarGiftRequest struct { Data []byte `json:"-"` } +type ImportOfficialStarGiftRequest 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 { CommandMeta GiftID int64 `json:"gift_id"` @@ -796,8 +829,9 @@ func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest) req.ContentSHA = hex.EncodeToString(animation.SHA256) return s.runCommand(ctx, req.CommandMeta, ActionImportStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) { details := map[string]any{ - "gift_id": req.GiftID, "title": strings.TrimSpace(req.Title), "stars": req.Stars, - "convert_stars": req.ConvertStars, "enabled": req.Enabled, "sort_order": req.SortOrder, + "gift_id": strconv.FormatInt(req.GiftID, 10), "title": strings.TrimSpace(req.Title), + "stars": strconv.FormatInt(req.Stars, 10), "convert_stars": strconv.FormatInt(req.ConvertStars, 10), + "enabled": req.Enabled, "sort_order": req.SortOrder, "source_format": animation.SourceFormat, "source_name": animation.SourceName, "sha256": req.ContentSHA, "width": animation.Width, "height": animation.Height, "frame_rate": animation.FrameRate, "compressed_bytes": len(animation.TGS), "json_bytes": len(animation.JSON), @@ -813,13 +847,232 @@ func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest) if err != nil { return CommandResult{Details: details}, err } - details["gift_id"] = entry.Gift.ID - details["revision_id"] = entry.Gift.RevisionID + details["gift_id"] = strconv.FormatInt(entry.Gift.ID, 10) + details["revision_id"] = strconv.FormatInt(entry.Gift.RevisionID, 10) details["revision"] = entry.Revision return CommandResult{Message: "star gift imported", Details: details}, nil }) } +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) +} + +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 + } + 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 +} + +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") + } + 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 { + 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 + } + 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: 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)]++ + } + 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: strings.TrimSpace(value.Name), RarityKind: rarityKind, RarityPermille: permille, + OfficialDocumentID: value.DocumentID, SortOrder: index, Animation: &animation}) + assetHashes = append(assetHashes, value.Document.SHA256) + rarityCounts[string(rarityKind)]++ + } + 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: 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. + Limited: false, 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 + } + if req.DryRun { + return CommandResult{Message: "official star gift bundle validated", 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 + }) +} + +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") @@ -833,8 +1086,9 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt } uploads[i].ContentSHA = hex.EncodeToString(animation.SHA256) attributes[i] = domain.StarGiftCollectibleAttribute{ - Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityPermille: uploads[i].RarityPermille, - SortOrder: uploads[i].SortOrder, Animation: &animation, + Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityKind: domain.StarGiftRarityPermille, + RarityPermille: uploads[i].RarityPermille, + SortOrder: uploads[i].SortOrder, Animation: &animation, } } return attributes, nil @@ -852,7 +1106,8 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt backdrops[i] = domain.StarGiftCollectibleAttribute{ Kind: domain.StarGiftCollectibleBackdrop, Name: strings.TrimSpace(backdrop.Name), BackdropID: backdrop.BackdropID, CenterColor: backdrop.CenterColor, EdgeColor: backdrop.EdgeColor, PatternColor: backdrop.PatternColor, - TextColor: backdrop.TextColor, RarityPermille: backdrop.RarityPermille, SortOrder: backdrop.SortOrder, + TextColor: backdrop.TextColor, RarityKind: domain.StarGiftRarityPermille, + RarityPermille: backdrop.RarityPermille, SortOrder: backdrop.SortOrder, } } write := domain.StarGiftCollectibleWrite{ @@ -873,8 +1128,9 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt } return s.runCommand(ctx, req.CommandMeta, ActionPublishGiftCollectibles, 0, domain.Peer{}, req, func() (CommandResult, error) { details := map[string]any{ - "gift_id": req.GiftID, "upgrade_stars": req.UpgradeStars, "supply_total": req.SupplyTotal, - "slug_prefix": write.SlugPrefix, "models": collectibleUploadDetails(req.Models), + "gift_id": strconv.FormatInt(req.GiftID, 10), "upgrade_stars": strconv.FormatInt(req.UpgradeStars, 10), + "supply_total": req.SupplyTotal, + "slug_prefix": write.SlugPrefix, "models": collectibleUploadDetails(req.Models), "patterns": collectibleUploadDetails(req.Patterns), "backdrops": len(req.Backdrops), } if req.DryRun { @@ -884,7 +1140,7 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt if err != nil { return CommandResult{Details: details}, err } - details["revision_id"] = revision.ID + details["revision_id"] = strconv.FormatInt(revision.ID, 10) details["revision"] = revision.Revision details["published"] = revision.Published return CommandResult{Message: "star gift collectible pool published", Details: details}, nil @@ -907,7 +1163,7 @@ func (s *Service) SetStarGiftEnabled(ctx context.Context, req SetStarGiftEnabled return CommandResult{}, fmt.Errorf("valid star gift and service are required") } return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftEnabled, 0, domain.Peer{}, req, func() (CommandResult, error) { - details := map[string]any{"gift_id": req.GiftID, "enabled": req.Enabled} + details := map[string]any{"gift_id": strconv.FormatInt(req.GiftID, 10), "enabled": req.Enabled} if req.DryRun { return CommandResult{Message: "star gift state change validated", Details: details}, nil } @@ -922,7 +1178,7 @@ func (s *Service) SetStarGiftSortOrder(ctx context.Context, req SetStarGiftSortO return CommandResult{}, fmt.Errorf("valid star gift and service are required") } return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) { - details := map[string]any{"gift_id": req.GiftID, "sort_order": req.SortOrder} + details := map[string]any{"gift_id": strconv.FormatInt(req.GiftID, 10), "sort_order": req.SortOrder} if req.DryRun { return CommandResult{Message: "star gift order change validated", Details: details}, nil } diff --git a/internal/admin/service_test.go b/internal/admin/service_test.go index 209f6d56..471ee0e1 100644 --- a/internal/admin/service_test.go +++ b/internal/admin/service_test.go @@ -1,6 +1,7 @@ package admin import ( + "bytes" "context" "crypto/sha256" "errors" @@ -10,6 +11,7 @@ import ( "time" "telesrv/internal/domain" + "telesrv/internal/officialgifts" ) func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) { @@ -710,7 +712,7 @@ func TestImportStarGiftDryRunThenConfirm(t *testing.T) { } base.CommandMeta = CommandMeta{CommandID: "exec-gift", Actor: "ops", Reason: "catalog", DryRun: false} result, err := svc.ImportStarGift(context.Background(), base) - if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != int64(22) { + if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "22" { t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls) } } @@ -747,12 +749,88 @@ func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) { } base.CommandMeta = CommandMeta{CommandID: "exec-collectibles", Actor: "ops", Reason: "pool", DryRun: false} result, err := svc.PublishStarGiftCollectibles(context.Background(), base) - if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != int64(33) || result.Details["published"] != true { + if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "33" || result.Details["published"] != true { t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls) } } -type fakeGiftsService struct{ createCalls int } +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 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 +} func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) { sum := sha256.Sum256(data) @@ -761,10 +839,24 @@ 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) PrepareOfficialAnimation(name string, data []byte) (domain.StarGiftAnimation, error) { + return f.PrepareAnimation(name, data) +} func (f *fakeGiftsService) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) { f.createCalls++ return domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Stars}, Revision: 1}, nil } +func (f *fakeGiftsService) CreateCatalogBundle(_ context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) { + f.createCalls++ + f.lastBundle = write + entry := domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Catalog.Stars}, Revision: 1} + result := domain.StarGiftCatalogBundleResult{Catalog: entry} + if write.Collectible != nil { + revision := domain.StarGiftCollectibleRevision{ID: 33, GiftID: 11, Revision: 1, Published: true} + result.Collectible = &revision + } + return result, nil +} func (*fakeGiftsService) SetCatalogEnabled(context.Context, int64, bool) (bool, error) { return true, nil } diff --git a/internal/adminapi/server.go b/internal/adminapi/server.go index 0312bb71..e7f07f14 100644 --- a/internal/adminapi/server.go +++ b/internal/adminapi/server.go @@ -4,6 +4,7 @@ import ( "context" "crypto/subtle" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,6 +16,7 @@ import ( "telesrv/internal/admin" "telesrv/internal/domain" + "telesrv/internal/officialgifts" ) type Config struct { @@ -32,6 +34,9 @@ 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) + OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error) + OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]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) @@ -95,6 +100,9 @@ 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/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)) @@ -221,6 +229,60 @@ 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()) + 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")) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !found { + writeError(w, http.StatusNotFound, "official gift animation not found") + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "private, max-age=60") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(raw) +} + +func (s *Server) handleImportOfficialStarGift(w http.ResponseWriter, r *http.Request) { + var req admin.ImportOfficialStarGiftRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.ImportOfficialStarGift(r.Context(), req) + writeCommandResult(w, result, err) +} + func (s *Server) handlePublishStarGiftCollectibles(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) @@ -338,7 +400,7 @@ func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Reque return } if !found { - writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": giftID}) + writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": strconv.FormatInt(giftID, 10)}) return } writeJSON(w, http.StatusOK, collectiblePreviewResponse(preview)) @@ -347,8 +409,10 @@ func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Reque func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[string]any { attribute := func(value domain.StarGiftCollectibleAttribute) map[string]any { result := map[string]any{ - "id": value.ID, "name": value.Name, "rarity_permille": value.RarityPermille, - "sort_order": value.SortOrder, "kind": value.Kind, + "id": strconv.FormatInt(value.ID, 10), "name": value.Name, "rarity_kind": value.RarityKind, + "rarity_permille": value.RarityPermille, "crafted": value.Crafted, + "official_document_id": strconv.FormatInt(value.OfficialDocumentID, 10), + "sort_order": value.SortOrder, "kind": value.Kind, } if value.Animation != nil { result["source_name"] = value.Animation.SourceName @@ -371,8 +435,9 @@ func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[strin return result } return map[string]any{ - "found": true, "gift_id": preview.GiftID, "revision": preview.Revision, "upgrade_stars": preview.UpgradeStars, - "supply_total": preview.SupplyTotal, "issued": preview.Issued, + "found": true, "gift_id": strconv.FormatInt(preview.GiftID, 10), "revision": preview.Revision, + "upgrade_stars": strconv.FormatInt(preview.UpgradeStars, 10), + "supply_total": preview.SupplyTotal, "issued": preview.Issued, "slug_prefix": preview.SlugPrefix, "models": mapAttributes(preview.Models), "patterns": mapAttributes(preview.Patterns), "backdrops": mapAttributes(preview.Backdrops), diff --git a/internal/adminapi/server_test.go b/internal/adminapi/server_test.go index e031bc83..eb97efd4 100644 --- a/internal/adminapi/server_test.go +++ b/internal/adminapi/server_test.go @@ -11,6 +11,7 @@ import ( "telesrv/internal/admin" "telesrv/internal/domain" + "telesrv/internal/officialgifts" ) func TestAdminAPIRequiresBearerToken(t *testing.T) { @@ -151,6 +152,49 @@ func TestAdminAPIPublishStarGiftCollectiblesMultipart(t *testing.T) { } } +func TestCollectiblePreviewResponsePreservesInt64AsDecimalStrings(t *testing.T) { + const maxInt64 = int64(9223372036854775807) + got := collectiblePreviewResponse(domain.StarGiftUpgradePreview{ + GiftID: maxInt64, + UpgradeStars: maxInt64, + Models: []domain.StarGiftCollectibleAttribute{{ + ID: maxInt64, + Kind: domain.StarGiftCollectibleModel, + Name: "Exact", + RarityKind: domain.StarGiftRarityPermille, + RarityPermille: 1000, + OfficialDocumentID: maxInt64, + }}, + }) + if got["gift_id"] != "9223372036854775807" || got["upgrade_stars"] != "9223372036854775807" { + t.Fatalf("preview ids = %#v", got) + } + models, ok := got["models"].([]map[string]any) + if !ok || len(models) != 1 { + t.Fatalf("preview models = %#v", got["models"]) + } + if models[0]["id"] != "9223372036854775807" || models[0]["official_document_id"] != "9223372036854775807" { + t.Fatalf("preview model ids = %#v", models[0]) + } +} + +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 { @@ -219,6 +263,18 @@ 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) { + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + +func (fakeService) OfficialStarGifts(context.Context) ([]officialgifts.GiftSummary, error) { + return nil, nil +} + +func (fakeService) OfficialStarGiftAnimation(context.Context, string) ([]byte, bool, error) { + return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil +} + func (fakeService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) { return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil } diff --git a/internal/app/stargifts/animation.go b/internal/app/stargifts/animation.go index b8d23ea4..0df97e9e 100644 --- a/internal/app/stargifts/animation.go +++ b/internal/app/stargifts/animation.go @@ -21,7 +21,18 @@ func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGif return prepareAnimation(fileName, data) } +// PrepareOfficialAnimation preserves expressions present in Telegram's signed-in official +// snapshot. Callers must first verify the file against manifest size and SHA-256; ordinary +// operator uploads continue through PrepareAnimation and reject expressions. +func (s *Service) PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) { + return prepareAnimationWithPolicy(fileName, data, true) +} + func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) { + return prepareAnimationWithPolicy(fileName, data, false) +} + +func prepareAnimationWithPolicy(fileName string, data []byte, allowExpressions bool) (domain.StarGiftAnimation, error) { fileName = strings.TrimSpace(filepath.Base(fileName)) ext := strings.ToLower(filepath.Ext(fileName)) format := domain.StarGiftAnimationLottie @@ -46,7 +57,7 @@ func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, e rawJSON = data } - normalized, meta, err := normalizeAndValidateLottie(rawJSON) + normalized, meta, err := normalizeAndValidateLottie(rawJSON, allowExpressions) if err != nil { return domain.StarGiftAnimation{}, err } @@ -80,7 +91,7 @@ type lottieMetadata struct { Assets []json.RawMessage `json:"assets"` } -func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) { +func normalizeAndValidateLottie(data []byte, allowExpressions bool) ([]byte, lottieMetadata, error) { data = bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})) if len(data) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes || !json.Valid(data) { return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid @@ -94,7 +105,7 @@ func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) { if _, ok := root.(map[string]any); !ok { return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid } - if containsLottieExpression(root) { + if !allowExpressions && containsLottieExpression(root) { return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid) } var meta lottieMetadata diff --git a/internal/app/stargifts/animation_test.go b/internal/app/stargifts/animation_test.go index c21794ca..195a0898 100644 --- a/internal/app/stargifts/animation_test.go +++ b/internal/app/stargifts/animation_test.go @@ -91,3 +91,26 @@ func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) { t.Fatalf("disable missing err=%v, want ErrStarGiftNotFound", err) } } + +func TestCreateCatalogBundleRejectsMismatchedOfficialProvenance(t *testing.T) { + ctx := context.Background() + store := memory.NewStarGiftStore() + svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2) + animation, err := svc.PrepareAnimation("gift.json", []byte(validGiftLottie)) + if err != nil { + t.Fatal(err) + } + hash := make([]byte, sha256.Size) + _, err = svc.CreateCatalogBundle(ctx, domain.StarGiftCatalogBundleWrite{ + Catalog: domain.StarGiftCatalogWrite{ + Stars: 50, ConvertStars: 25, Enabled: true, Title: "Official", Animation: animation, + OfficialGiftID: 10, SourceManifestSHA256: hash, OfficialSourceJSON: []byte(`{"id":10}`), + }, + Collectible: &domain.StarGiftCollectibleWrite{ + OfficialGiftID: 11, SourceManifestSHA256: hash, + }, + }) + if !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + t.Fatalf("mismatched provenance err=%v, want ErrStarGiftCollectibleInvalid", err) + } +} diff --git a/internal/app/stargifts/local_withdrawal.go b/internal/app/stargifts/local_withdrawal.go new file mode 100644 index 00000000..dcbc9896 --- /dev/null +++ b/internal/app/stargifts/local_withdrawal.go @@ -0,0 +1,50 @@ +package stargifts + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net/url" + "strings" + "time" +) + +const localWithdrawalTTL = 15 * time.Minute + +// LocalWithdrawalProvider implements the TON/export UX entirely inside +// telesrv. It mints an unguessable, short-lived bearer URL; no external +// blockchain, Fragment endpoint, wallet or network RPC is contacted. +type LocalWithdrawalProvider struct { + publicBaseURL string +} + +func NewLocalWithdrawalProvider(publicBaseURL string) (*LocalWithdrawalProvider, error) { + publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/") + parsed, err := url.Parse(publicBaseURL) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || + (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, fmt.Errorf("invalid local star gift withdrawal base URL") + } + return &LocalWithdrawalProvider{publicBaseURL: publicBaseURL}, nil +} + +func (p *LocalWithdrawalProvider) Name() string { return "telesrv-local" } + +func (p *LocalWithdrawalProvider) CreateWithdrawal(_ context.Context, _ StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error) { + if p == nil || p.publicBaseURL == "" { + return StarGiftWithdrawalProviderResult{}, fmt.Errorf("local star gift withdrawal provider is not configured") + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return StarGiftWithdrawalProviderResult{}, fmt.Errorf("generate local withdrawal token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(raw) + return StarGiftWithdrawalProviderResult{ + RequestID: token, + URL: p.publicBaseURL + "/gift-withdrawal/" + url.PathEscape(token), + ExpiresAt: int(time.Now().Add(localWithdrawalTTL).Unix()), + }, nil +} + +var _ StarGiftWithdrawalProvider = (*LocalWithdrawalProvider)(nil) diff --git a/internal/app/stargifts/local_withdrawal_test.go b/internal/app/stargifts/local_withdrawal_test.go new file mode 100644 index 00000000..059c8831 --- /dev/null +++ b/internal/app/stargifts/local_withdrawal_test.go @@ -0,0 +1,34 @@ +package stargifts + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestLocalWithdrawalProviderIsInternalAndBounded(t *testing.T) { + for _, invalid := range []string{"", "ftp://example.test", "https://user@example.test", "https://example.test/?token=bad", "https://example.test/#bad"} { + if _, err := NewLocalWithdrawalProvider(invalid); err == nil { + t.Fatalf("invalid withdrawal base URL %q accepted", invalid) + } + } + provider, err := NewLocalWithdrawalProvider("https://example.test/base/") + if err != nil { + t.Fatal(err) + } + before := time.Now() + result, err := provider.CreateWithdrawal(context.Background(), StarGiftWithdrawalProviderRequest{}) + if err != nil { + t.Fatal(err) + } + if provider.Name() != "telesrv-local" || len(result.RequestID) != 43 || + result.URL != "https://example.test/base/gift-withdrawal/"+result.RequestID || + strings.ContainsAny(result.RequestID, "+/=") { + t.Fatalf("local withdrawal result = %+v", result) + } + expires := time.Unix(int64(result.ExpiresAt), 0) + if expires.Before(before.Add(14*time.Minute)) || expires.After(before.Add(16*time.Minute)) { + t.Fatalf("local withdrawal expiry = %v, want about 15 minutes", expires) + } +} diff --git a/internal/app/stargifts/official_snapshot_test.go b/internal/app/stargifts/official_snapshot_test.go new file mode 100644 index 00000000..85f52835 --- /dev/null +++ b/internal/app/stargifts/official_snapshot_test.go @@ -0,0 +1,56 @@ +package stargifts_test + +import ( + "context" + "os" + "testing" + + "telesrv/internal/app/stargifts" + "telesrv/internal/officialgifts" +) + +// This opt-in test is run by the official import audit. It validates every distinct base, +// model and pattern document with the trusted official animation policy, including the +// small set of Telegram-authored expression animations. +func TestConfiguredOfficialSnapshotAnimations(t *testing.T) { + root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR") + if root == "" { + t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set") + } + catalog := officialgifts.New(root) + items, err := catalog.List(context.Background()) + if err != nil { + t.Fatal(err) + } + service := &stargifts.Service{} + seen := map[int64]struct{}{} + validate := func(document officialgifts.Document) { + t.Helper() + if _, ok := seen[document.ID]; ok { + return + } + seen[document.ID] = struct{}{} + if _, err := service.PrepareOfficialAnimation(document.FileName, document.Data); err != nil { + t.Fatalf("document %d (%s): %v", document.ID, document.Path, err) + } + } + for _, item := range items { + bundle, err := catalog.Bundle(context.Background(), item.ID, item.ModelCount+item.PatternCount+item.BackdropCount > 0) + if err != nil { + t.Fatalf("gift %d: %v", item.ID, err) + } + validate(bundle.BaseDocument) + if bundle.Collectible == nil { + continue + } + for _, model := range bundle.Collectible.Models { + validate(model.Document) + } + for _, pattern := range bundle.Collectible.Patterns { + validate(pattern.Document) + } + } + if len(seen) != 8333 { + t.Fatalf("validated %d documents, want 8333", len(seen)) + } +} diff --git a/internal/app/stargifts/service.go b/internal/app/stargifts/service.go index 43309748..0b4e2e75 100644 --- a/internal/app/stargifts/service.go +++ b/internal/app/stargifts/service.go @@ -2,9 +2,12 @@ package stargifts import ( + "bytes" "context" "crypto/rand" + "encoding/base64" "encoding/binary" + "encoding/json" "fmt" "strings" "sync" @@ -22,26 +25,65 @@ type BlobBackend interface { } type Service struct { - store store.StarGiftStore - upgrades store.StarGiftUpgradeStore - blobs BlobBackend - dc int + store store.StarGiftStore + upgrades store.StarGiftUpgradeStore + lifecycle store.StarGiftLifecycleStore + withdrawal StarGiftWithdrawalProvider + blobs BlobBackend + dc int mu sync.RWMutex built bool gifts []domain.StarGift byID map[int64]domain.StarGift hash int + + formMu sync.Mutex + forms map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm } +type starGiftPurchaseFormKey struct { + buyerUserID int64 + formID int64 +} + +// AtomicPurchaseConfigured reports whether the production aggregate +// coordinator is installed. It lets the RPC package keep its isolated memory +// test adapter without silently downgrading PostgreSQL deployments. +func (s *Service) AtomicPurchaseConfigured() bool { return s != nil && s.lifecycle != nil } + type Option func(*Service) func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option { return func(service *Service) { service.upgrades = upgrades } } +func WithLifecycleStore(lifecycle store.StarGiftLifecycleStore) Option { + return func(service *Service) { service.lifecycle = lifecycle } +} + +type StarGiftWithdrawalProvider interface { + Name() string + CreateWithdrawal(ctx context.Context, req StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error) +} + +type StarGiftWithdrawalProviderRequest struct { + UserID int64 + Gift domain.UniqueStarGift +} + +type StarGiftWithdrawalProviderResult struct { + RequestID string + URL string + ExpiresAt int +} + +func WithWithdrawalProvider(provider StarGiftWithdrawalProvider) Option { + return func(service *Service) { service.withdrawal = provider } +} + func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service { - service := &Service{store: st, blobs: blobs, dc: dc} + service := &Service{store: st, blobs: blobs, dc: dc, forms: make(map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm)} for _, opt := range opts { opt(service) } @@ -137,21 +179,33 @@ func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGi len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes { return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid } + if err := s.materializeCatalogWrite(ctx, &write); err != nil { + return domain.StarGiftCatalogEntry{}, err + } + entry, err := s.store.CreateCatalogRevision(ctx, write) + if err != nil { + return domain.StarGiftCatalogEntry{}, err + } + s.InvalidateStarGiftCatalog() + return entry, nil +} + +func (s *Service) materializeCatalogWrite(ctx context.Context, write *domain.StarGiftCatalogWrite) error { objectKey, err := s.blobs.Put(ctx, write.Animation.TGS) if err != nil { - return domain.StarGiftCatalogEntry{}, fmt.Errorf("store star gift animation: %w", err) + return fmt.Errorf("store star gift animation: %w", err) } documentID, err := randomPositiveInt64() if err != nil { - return domain.StarGiftCatalogEntry{}, err + return err } accessHash, err := randomPositiveInt64() if err != nil { - return domain.StarGiftCatalogEntry{}, err + return err } fileReference := make([]byte, 16) if _, err := rand.Read(fileReference); err != nil { - return domain.StarGiftCatalogEntry{}, fmt.Errorf("generate star gift file reference: %w", err) + return fmt.Errorf("generate star gift file reference: %w", err) } write.Document = domain.Document{ ID: documentID, @@ -175,12 +229,66 @@ func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGi 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 + return nil +} + +// CreateCatalogBundle materializes every verified asset before publishing both active +// revision pointers in one store transaction. Blob writes are content-addressed and may be +// safely orphaned for later GC if the database transaction fails. +func (s *Service) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) { + if s == nil || s.store == nil || s.blobs == nil { + return domain.StarGiftCatalogBundleResult{}, fmt.Errorf("star gift catalog importer is not configured") } - s.InvalidateStarGiftCatalog() - return entry, nil + write.Catalog.Title = strings.TrimSpace(write.Catalog.Title) + if write.Catalog.Stars <= 0 || write.Catalog.ConvertStars < 0 || write.Catalog.ConvertStars > write.Catalog.Stars || + write.Catalog.Animation.Width != 512 || write.Catalog.Animation.Height != 512 || len(write.Catalog.Animation.TGS) == 0 || + len([]rune(write.Catalog.Title)) > domain.MaxStarGiftTitleRunes { + return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid + } + var officialSource map[string]any + if write.Catalog.OfficialGiftID < 0 { + return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid + } + if write.Catalog.OfficialGiftID > 0 && (len(write.Catalog.SourceManifestSHA256) != 32 || + json.Unmarshal(write.Catalog.OfficialSourceJSON, &officialSource) != nil || officialSource == nil) { + return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid + } + if write.Catalog.OfficialGiftID == 0 && (len(write.Catalog.SourceManifestSHA256) != 0 || len(write.Catalog.OfficialSourceJSON) != 0) { + return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid + } + if write.Collectible != nil { + write.Collectible.SlugPrefix = strings.ToLower(strings.TrimSpace(write.Collectible.SlugPrefix)) + if write.Collectible.OfficialGiftID != write.Catalog.OfficialGiftID || + !bytes.Equal(write.Collectible.SourceManifestSHA256, write.Catalog.SourceManifestSHA256) { + return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftCollectibleInvalid + } + validation := *write.Collectible + if validation.GiftID == 0 { + validation.GiftID = write.Catalog.GiftID + if validation.GiftID == 0 { + validation.GiftID = 1 + } + } + if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + } + if err := s.materializeCatalogWrite(ctx, &write.Catalog); err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + if write.Collectible != nil { + if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Models); err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Patterns); err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + } + result, err := s.store.CreateCatalogBundle(ctx, write) + if err == nil { + s.InvalidateStarGiftCatalog() + } + return result, err } func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) { @@ -225,55 +333,56 @@ func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.St 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 { + if err := s.materializeCollectibleAttributes(ctx, write.Models); err != nil { return domain.StarGiftCollectibleRevision{}, err } - if err := materialize(write.Patterns); err != nil { + if err := s.materializeCollectibleAttributes(ctx, write.Patterns); err != nil { return domain.StarGiftCollectibleRevision{}, err } return s.PublishCollectibleRevision(ctx, write) } +func (s *Service) materializeCollectibleAttributes(ctx context.Context, 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 +} + 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 @@ -335,6 +444,338 @@ func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest return result, err } +func (s *Service) UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) { + if s == nil || s.upgrades == nil { + return domain.StarGiftUpgradeReceipt{}, false, nil + } + return s.upgrades.StarGiftUpgradeReceipt(ctx, userID, commandKey) +} + +func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable + } + result, err := s.lifecycle.PurchaseStarGift(ctx, req) + if err == nil { + s.InvalidateStarGiftCatalog() + } + return result, err +} + +// IssuePurchaseForm creates one fresh payment intent. PostgreSQL persists the +// intent so server restarts cannot turn a valid checkout into an unbound +// payment. The bounded in-memory branch exists only for isolated RPC tests. +func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) { + if !validPurchaseForm(form) { + return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftFormPurposeInvalid + } + if s != nil && s.lifecycle != nil { + return s.lifecycle.IssueStarGiftPurchaseForm(ctx, form) + } + if s == nil { + return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable + } + s.formMu.Lock() + defer s.formMu.Unlock() + for key, existing := range s.forms { + if existing.ExpiresAt < form.IssuedAt { + delete(s.forms, key) + } + } + for attempt := 0; attempt < 8; attempt++ { + formID, err := randomPositiveInt64() + if err != nil { + return domain.StarGiftPurchaseForm{}, err + } + key := starGiftPurchaseFormKey{buyerUserID: form.BuyerUserID, formID: formID} + if _, exists := s.forms[key]; exists { + continue + } + form.FormID = formID + s.forms[key] = form + return form, nil + } + return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable +} + +// ValidatePurchaseForm is a read-only preflight used for precise RPC errors. +// The PostgreSQL purchase transaction repeats this validation while holding a +// row lock; callers must not treat this preflight as the atomicity boundary. +func (s *Service) ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error { + if s != nil && s.lifecycle != nil { + return s.lifecycle.ValidateStarGiftPurchaseForm(ctx, req) + } + if s == nil || req.FormID == 0 { + return domain.ErrStarGiftFormExpired + } + s.formMu.Lock() + defer s.formMu.Unlock() + form, ok := s.forms[starGiftPurchaseFormKey{buyerUserID: req.BuyerUserID, formID: req.FormID}] + if !ok || form.ExpiresAt < req.Date { + return domain.ErrStarGiftFormExpired + } + return validatePurchaseFormIntent(form, req) +} + +func validPurchaseForm(form domain.StarGiftPurchaseForm) bool { + return form.FormID == 0 && form.BuyerUserID > 0 && form.To.ID > 0 && + (form.To.Type == domain.PeerTypeUser || form.To.Type == domain.PeerTypeChannel) && + form.GiftID > 0 && form.RevisionID > 0 && form.ChargeStars > 0 && form.IssuedAt > 0 && + form.ExpiresAt == form.IssuedAt+600 && len([]rune(form.Message)) <= 128 +} + +func validatePurchaseFormIntent(form domain.StarGiftPurchaseForm, req domain.StarGiftPurchaseRequest) error { + if form.BuyerUserID != req.BuyerUserID || form.To != req.To || form.GiftID != req.GiftID || + form.IncludeUpgrade != req.IncludeUpgrade || form.HideName != req.HideName || form.Message != req.Message { + return domain.ErrStarGiftFormPurposeInvalid + } + if form.RevisionID != req.RevisionID || form.ChargeStars != req.ChargeStars { + return domain.ErrStarGiftFormAmountMismatch + } + return nil +} + +func (s *Service) ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + return s.lifecycle.ListResaleStarGifts(ctx, filter) +} + +func (s *Service) ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftValueInfo{}, domain.ErrStarGiftResaleUnavailable + } + return s.lifecycle.UniqueStarGiftValueInfo(ctx, uniqueGiftID) +} + +func (s *Service) SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) { + if s == nil || s.lifecycle == nil { + return domain.UniqueStarGift{}, domain.ErrStarGiftResaleUnavailable + } + result, err := s.lifecycle.SetStarGiftListing(ctx, req) + if err == nil { + s.InvalidateStarGiftCatalog() + } + return result, err +} + +func (s *Service) Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable + } + return s.lifecycle.TransferStarGift(ctx, req) +} + +func (s *Service) PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable + } + result, err := s.lifecycle.PurchaseResaleStarGift(ctx, req) + if err == nil { + s.InvalidateStarGiftCatalog() + } + return result, err +} + +func (s *Service) SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + return s.lifecycle.SendStarGiftOffer(ctx, req) +} + +func (s *Service) ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + return s.lifecycle.ResolveStarGiftOffer(ctx, req) +} + +func (s *Service) ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) { + if s == nil || s.lifecycle == nil { + return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable + } + return s.lifecycle.ListCraftStarGifts(ctx, userID, giftID, offset, limit) +} + +func (s *Service) Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + return s.lifecycle.CraftStarGift(ctx, req) +} + +func (s *Service) AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftAuction{}, domain.ErrStarGiftAuctionUnavailable + } + return s.lifecycle.StarGiftAuctionState(ctx, userID, giftID, slug, now) +} + +func (s *Service) ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) { + if s == nil || s.lifecycle == nil { + return nil, domain.ErrStarGiftAuctionUnavailable + } + return s.lifecycle.ActiveStarGiftAuctions(ctx, userID, now) +} + +func (s *Service) AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) { + if s == nil || s.lifecycle == nil { + return nil, domain.ErrStarGiftAuctionUnavailable + } + return s.lifecycle.StarGiftAuctionAcquired(ctx, userID, giftID) +} + +func (s *Service) BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftAuction{}, domain.StarsBalance{}, domain.ErrStarGiftAuctionUnavailable + } + return s.lifecycle.BidStarGiftAuction(ctx, req) +} + +func (s *Service) PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) { + if s == nil || s.lifecycle == nil { + return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable + } + return s.lifecycle.PrepaidUpgradeTarget(ctx, owner, hash) +} + +func (s *Service) PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable + } + return s.lifecycle.PrepayStarGiftUpgrade(ctx, req) +} + +func (s *Service) DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable + } + return s.lifecycle.DropStarGiftOriginalDetails(ctx, req) +} + +func (s *Service) SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error { + if s == nil || s.lifecycle == nil { + return domain.ErrStarGiftUnavailable + } + return s.lifecycle.SetStarGiftNotifications(ctx, userID, channelID, enabled) +} + +func (s *Service) Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) { + if s == nil || s.lifecycle == nil || s.withdrawal == nil { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + saved, found, err := s.store.GetByRef(ctx, req.Ref) + if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) || + saved.UniqueGiftID == 0 || !saved.LifecycleStatus.Live() || saved.CanExportAt > req.Date { + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable + } + unique, found, err := s.store.UniqueByID(ctx, saved.UniqueGiftID) + if err != nil || !found || unique.Burned || unique.Owner != saved.Owner { + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable + } + providerResult, err := s.withdrawal.CreateWithdrawal(ctx, StarGiftWithdrawalProviderRequest{UserID: req.UserID, Gift: unique}) + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + if strings.TrimSpace(providerResult.RequestID) == "" || strings.TrimSpace(providerResult.URL) == "" || providerResult.ExpiresAt <= req.Date { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + recorded, err := s.lifecycle.RecordStarGiftWithdrawal(ctx, req, s.withdrawal.Name(), providerResult.RequestID, providerResult.URL, providerResult.ExpiresAt) + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + return recorded, nil +} + +func (s *Service) ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftWithdrawal{}, false, nil + } + return s.lifecycle.ResolveStarGiftWithdrawal(ctx, providerRequestID) +} + +func (s *Service) CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + return s.lifecycle.CompleteStarGiftWithdrawal(ctx, providerRequestID, date) +} + +func (s *Service) TonBalance(ctx context.Context, userID int64) (int64, error) { + if s == nil || s.lifecycle == nil { + return 0, nil + } + return s.lifecycle.TonBalance(ctx, userID) +} + +func (s *Service) TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) { + if s == nil || s.lifecycle == nil { + return domain.TonTransactionPage{}, nil + } + if len(offset) > domain.MaxStarsTransactionsOffsetBytes { + offset = "" + } + if limit <= 0 || limit > domain.MaxStarsTransactionsLimit { + limit = domain.MaxStarsTransactionsLimit + } + return s.lifecycle.TonTransactions(ctx, userID, offset, limit) +} + +func (s *Service) ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) { + if s == nil || s.lifecycle == nil { + return 0, nil + } + return s.lifecycle.ChannelStarsBalance(ctx, channelID) +} + +func (s *Service) ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) { + if s == nil || s.lifecycle == nil { + return domain.StarsTransactionPage{}, nil + } + if len(offset) > domain.MaxStarsTransactionsOffsetBytes { + offset = "" + } + if limit <= 0 || limit > domain.MaxStarsTransactionsLimit { + limit = domain.MaxStarsTransactionsLimit + } + return s.lifecycle.ChannelStarsTransactions(ctx, channelID, offset, limit) +} + +func (s *Service) ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) { + if s == nil || s.lifecycle == nil { + return 0, nil + } + return s.lifecycle.ChannelTonBalance(ctx, channelID) +} + +func (s *Service) ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) { + if s == nil || s.lifecycle == nil { + return domain.TonTransactionPage{}, nil + } + if len(offset) > domain.MaxStarsTransactionsOffsetBytes { + offset = "" + } + if limit <= 0 || limit > domain.MaxStarsTransactionsLimit { + limit = domain.MaxStarsTransactionsLimit + } + return s.lifecycle.ChannelTonTransactions(ctx, channelID, offset, limit) +} + +func (s *Service) SweepLifecycle(ctx context.Context, now, limit int) error { + if s == nil || s.lifecycle == nil { + return nil + } + return s.lifecycle.SweepStarGiftLifecycle(ctx, now, limit) +} + func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) { return s.store.ListCollections(ctx, owner) } @@ -360,6 +801,17 @@ func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs } func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) { + if gift.UniqueGiftID == 0 && gift.PrepaidUpgradeStars == 0 && gift.PrepaidUpgradeHash == "" && s.store != nil { + if revision, ok, err := s.store.ActiveCollectibleRevision(ctx, gift.GiftID); err != nil { + return 0, err + } else if ok && revision.Published && revision.Issued < revision.SupplyTotal { + var token [32]byte + if _, err := rand.Read(token[:]); err != nil { + return 0, fmt.Errorf("generate prepaid star gift upgrade hash: %w", err) + } + gift.PrepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:]) + } + } return s.store.Create(ctx, gift) } @@ -396,10 +848,20 @@ func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, return s.store.SetUnsaved(ctx, ref, unsaved) } +// Convert keeps the in-memory/catalog store primitive available to isolated +// tests and non-production adapters. RPC production paths must use +// ConvertAggregate so balance credit and terminal state cannot split. func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) { return s.store.MarkConverted(ctx, ref) } +func (s *Service) ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftConvertResult{}, domain.ErrStarGiftUnavailable + } + return s.lifecycle.ConvertStarGift(ctx, req) +} + func randomPositiveInt64() (int64, error) { var raw [8]byte if _, err := rand.Read(raw[:]); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 702ed312..ed9aacfc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -171,6 +171,11 @@ type Config struct { WebPagePreviewRatePerMin int // LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。 LangPackSeedDir string + // OfficialGiftsDir 是 cmd/giftfetch 生成的只读官方礼物快照目录。 + OfficialGiftsDir string + // StarGiftTONStartingGrant 是 telesrv 内部 TON 账本首次访问时授予的 nanoton。 + // 该账本只用于自建服务端礼物链路,不连接任何外部区块链。 + StarGiftTONStartingGrant int64 // BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。 BlobDir string // StickerSeedDir 是 reaction / sticker 资源种子目录(导入到 documents/sticker_sets + blob)。 @@ -311,6 +316,21 @@ type Config struct { PremiumSweepInterval time.Duration // PremiumSweepBatch 是单次到期清理的最大行数。 PremiumSweepBatch int + // StarGiftSweepInterval drives offer expiry/refunds, auction rounds and their + // durable notification/delivery outboxes. It is entirely server-local. + StarGiftSweepInterval time.Duration + // StarGiftSweepBatch bounds rows/aggregates claimed by one sweep. + StarGiftSweepBatch int + StarGiftTransferStars int64 + StarGiftDropOriginalDetailsStars int64 + StarGiftOfferMinStars int + StarGiftStarsProceedsPermille int + StarGiftTONProceedsPermille int + StarGiftExportDelay time.Duration + StarGiftTransferDelay time.Duration + StarGiftResellDelay time.Duration + StarGiftCraftDelay time.Duration + StarGiftCraftChancePermille int // GroupCallCheckTTL 是群通话参与者保活水位的过期阈值(客户端 Connecting 态 // 4s 一跳;M1 起 SFU liveness reporter 同样刷新该水位)。 @@ -491,6 +511,8 @@ 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"), StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300), @@ -555,12 +577,24 @@ func Load() (Config, error) { CallSignalingRate: envIntOr("TELESRV_CALL_SIGNALING_RATE", 50), CallExpiryInterval: envDurationOr("TELESRV_CALL_EXPIRY_INTERVAL", time.Second), - PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3), - PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"), - PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil), - StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)), - PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute), - PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500), + PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3), + PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"), + PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil), + StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)), + PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute), + PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500), + StarGiftSweepInterval: envDurationOr("TELESRV_STARGIFT_SWEEP_INTERVAL", 15*time.Second), + StarGiftSweepBatch: envIntOr("TELESRV_STARGIFT_SWEEP_BATCH", 1000), + StarGiftTransferStars: int64(envIntOr("TELESRV_STARGIFT_TRANSFER_STARS", 25)), + StarGiftDropOriginalDetailsStars: int64(envIntOr("TELESRV_STARGIFT_DROP_DETAILS_STARS", 25)), + StarGiftOfferMinStars: envIntOr("TELESRV_STARGIFT_OFFER_MIN_STARS", 1), + StarGiftStarsProceedsPermille: envIntOr("TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE", 1000), + StarGiftTONProceedsPermille: envIntOr("TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE", 1000), + StarGiftExportDelay: envDurationOr("TELESRV_STARGIFT_EXPORT_DELAY", 0), + StarGiftTransferDelay: envDurationOr("TELESRV_STARGIFT_TRANSFER_DELAY", 0), + StarGiftResellDelay: envDurationOr("TELESRV_STARGIFT_RESELL_DELAY", 0), + StarGiftCraftDelay: envDurationOr("TELESRV_STARGIFT_CRAFT_DELAY", 0), + StarGiftCraftChancePermille: envIntOr("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE", 250), GroupCallCheckTTL: envDurationOr("TELESRV_GROUPCALL_CHECK_TTL", 45*time.Second), GroupCallSweepInterval: envDurationOr("TELESRV_GROUPCALL_SWEEP_INTERVAL", 10*time.Second), @@ -592,9 +626,40 @@ func Load() (Config, error) { if err := validateRPCResultCacheConfig(cfg); err != nil { return Config{}, err } + if err := validateStarGiftConfig(cfg); err != nil { + return Config{}, err + } return cfg, nil } +func validateStarGiftConfig(cfg Config) error { + if cfg.StarGiftSweepInterval <= 0 || cfg.StarGiftSweepBatch <= 0 || cfg.StarGiftSweepBatch > 10000 { + return fmt.Errorf("TELESRV_STARGIFT_SWEEP_INTERVAL must be positive and TELESRV_STARGIFT_SWEEP_BATCH must be 1..10000") + } + if cfg.StarGiftTONStartingGrant < 0 { + return fmt.Errorf("TELESRV_STARGIFT_TON_STARTING_GRANT must be non-negative") + } + if cfg.StarGiftTransferStars < 0 || cfg.StarGiftDropOriginalDetailsStars < 0 || cfg.StarGiftOfferMinStars < 0 { + return fmt.Errorf("TELESRV_STARGIFT_TRANSFER_STARS, TELESRV_STARGIFT_DROP_DETAILS_STARS and TELESRV_STARGIFT_OFFER_MIN_STARS must be non-negative") + } + if cfg.StarGiftExportDelay < 0 || cfg.StarGiftTransferDelay < 0 || cfg.StarGiftResellDelay < 0 || cfg.StarGiftCraftDelay < 0 { + return fmt.Errorf("TELESRV_STARGIFT lifecycle delays must be non-negative") + } + const maxProtocolDelay = time.Duration(1<<31-1) * time.Second + if cfg.StarGiftExportDelay > maxProtocolDelay || cfg.StarGiftTransferDelay > maxProtocolDelay || + cfg.StarGiftResellDelay > maxProtocolDelay || cfg.StarGiftCraftDelay > maxProtocolDelay { + return fmt.Errorf("TELESRV_STARGIFT lifecycle delays exceed the protocol int32 date range") + } + if cfg.StarGiftCraftChancePermille < 0 || cfg.StarGiftCraftChancePermille > 1000 { + return fmt.Errorf("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE must be 0..1000") + } + if cfg.StarGiftStarsProceedsPermille < 0 || cfg.StarGiftStarsProceedsPermille > 1000 || + cfg.StarGiftTONProceedsPermille < 0 || cfg.StarGiftTONProceedsPermille > 1000 { + return fmt.Errorf("TELESRV_STARGIFT_*_PROCEEDS_PERMILLE must be 0..1000") + } + return nil +} + const mtProtoRPCResultMinBytes = int64((1 << 24) - (2 << 10)) func validateRPCResultCacheConfig(cfg Config) error { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 880d5e11..ebc5cf2f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -519,6 +519,19 @@ func TestLoadRejectsNonTelesrvConfigKeys(t *testing.T) { } } +func TestValidateStarGiftConfigRejectsNegativeInternalTONGrant(t *testing.T) { + cfg := Config{ + StarGiftSweepInterval: time.Second, + StarGiftSweepBatch: 1, + StarGiftTONStartingGrant: -1, + StarGiftStarsProceedsPermille: 1000, + StarGiftTONProceedsPermille: 1000, + } + if err := validateStarGiftConfig(cfg); err == nil { + t.Fatal("negative internal TON starting grant was accepted") + } +} + func writeConfigFile(t *testing.T, path, body string) { t.Helper() if err := os.WriteFile(path, []byte(body), 0o600); err != nil { diff --git a/internal/domain/channel.go b/internal/domain/channel.go index da8aa622..c6f81598 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -550,6 +550,9 @@ const ( ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price" // ChannelActionStarGift 映射 messageActionStarGift:频道礼物的 admin-log 快照。 ChannelActionStarGift ChannelMessageActionType = "star_gift" + // ChannelActionStarGiftUnique 映射 messageActionStarGiftUnique:collectible + // 升级、转赠等所有权变更只进入 Recent Actions,不伪造频道历史/pts。 + ChannelActionStarGiftUnique ChannelMessageActionType = "star_gift_unique" // ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper:频道外观页设置 wallpaper。 ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper" ) @@ -583,7 +586,8 @@ type ChannelMessageAction struct { Incompleted []int TodoItems []MessageTodoItem // StarGift 仅 star_gift 服务消息使用。 - StarGift *MessageStarGiftAction + StarGift *MessageStarGiftAction + StarGiftUnique *MessageStarGiftUniqueAction // Wallpaper 仅 set_chat_wallpaper 服务消息使用。 Wallpaper *Wallpaper // Photo 仅 chat_edit_photo 服务消息使用。 diff --git a/internal/domain/media.go b/internal/domain/media.go index 1df60ebf..01b2cead 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -564,7 +564,9 @@ const ( // MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The // immutable collectible snapshot is carried by the service message so an // exact replay/difference never depends on mutable catalog state. - MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique" + MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique" + MessageServiceActionStarGiftOffer MessageServiceActionKind = "star_gift_offer" + MessageServiceActionStarGiftOfferDeclined MessageServiceActionKind = "star_gift_offer_declined" ) // MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。 @@ -612,51 +614,87 @@ type MessageRequestedPeerAction struct { // MessageServiceAction 是私聊服务消息动作的协议中立表示。 type MessageServiceAction struct { - Kind MessageServiceActionKind `json:"kind"` - Photo *Photo `json:"photo,omitempty"` - Call *MessagePhoneCallAction `json:"call,omitempty"` - ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"` - BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"` - WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"` - RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"` - ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"` - StarGift *MessageStarGiftAction `json:"star_gift,omitempty"` - StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"` + Kind MessageServiceActionKind `json:"kind"` + Photo *Photo `json:"photo,omitempty"` + Call *MessagePhoneCallAction `json:"call,omitempty"` + ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"` + BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"` + WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"` + RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"` + ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"` + StarGift *MessageStarGiftAction `json:"star_gift,omitempty"` + StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"` + StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"` + StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,omitempty"` } // MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价) // 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。 type MessageStarGiftAction struct { - GiftID int64 `json:"gift_id"` - Stars int64 `json:"stars"` - ConvertStars int64 `json:"convert_stars,omitempty"` - Title string `json:"title,omitempty"` - Sticker *Document `json:"sticker,omitempty"` - Message string `json:"message,omitempty"` - FromUserID int64 `json:"from_user_id,omitempty"` - PeerUserID int64 `json:"peer_user_id,omitempty"` - PeerChannelID int64 `json:"peer_channel_id,omitempty"` - SavedID int64 `json:"saved_id,omitempty"` - NameHidden bool `json:"name_hidden,omitempty"` - Saved bool `json:"saved,omitempty"` - Converted bool `json:"converted,omitempty"` - CanUpgrade bool `json:"can_upgrade,omitempty"` - PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"` - UpgradeStars int64 `json:"upgrade_stars,omitempty"` - UpgradeMsgID int `json:"upgrade_msg_id,omitempty"` + GiftID int64 `json:"gift_id"` + Stars int64 `json:"stars"` + ConvertStars int64 `json:"convert_stars,omitempty"` + Title string `json:"title,omitempty"` + Sticker *Document `json:"sticker,omitempty"` + Message string `json:"message,omitempty"` + FromUserID int64 `json:"from_user_id,omitempty"` + PeerUserID int64 `json:"peer_user_id,omitempty"` + PeerChannelID int64 `json:"peer_channel_id,omitempty"` + SavedID int64 `json:"saved_id,omitempty"` + NameHidden bool `json:"name_hidden,omitempty"` + Saved bool `json:"saved,omitempty"` + Converted bool `json:"converted,omitempty"` + CanUpgrade bool `json:"can_upgrade,omitempty"` + PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"` + PrepaidUpgradeHash string `json:"prepaid_upgrade_hash,omitempty"` + UpgradeSeparate bool `json:"upgrade_separate,omitempty"` + // UpgradePriceStars belongs to the inner StarGift.upgrade_stars field and + // is the price of a normal paid upgrade. UpgradeStars below belongs to the + // outer messageActionStarGift and is only the amount already prepaid by the + // sender. TDesktop uses these two fields to choose the paid vs free flow. + UpgradePriceStars int64 `json:"upgrade_price_stars,omitempty"` + UpgradeStars int64 `json:"upgrade_stars,omitempty"` + UpgradeMsgID int `json:"upgrade_msg_id,omitempty"` + GiftMsgID int `json:"gift_msg_id,omitempty"` + GiftNum int `json:"gift_num,omitempty"` + AuctionAcquired bool `json:"auction_acquired,omitempty"` + To Peer `json:"to,omitempty"` } -// MessageStarGiftUniqueAction is the protocol-neutral payload of an upgrade -// service message. Commercial transfer/resale/export fields are intentionally -// absent from the collectibles mainline. type MessageStarGiftUniqueAction struct { - Gift UniqueStarGift `json:"gift"` - FromUserID int64 `json:"from_user_id,omitempty"` - Peer Peer `json:"peer"` - SavedID int64 `json:"saved_id,omitempty"` - Upgrade bool `json:"upgrade,omitempty"` - Saved bool `json:"saved,omitempty"` - PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"` + Gift UniqueStarGift `json:"gift"` + FromUserID int64 `json:"from_user_id,omitempty"` + Peer Peer `json:"peer"` + SavedID int64 `json:"saved_id,omitempty"` + Upgrade bool `json:"upgrade,omitempty"` + Saved bool `json:"saved,omitempty"` + PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"` + Transferred bool `json:"transferred,omitempty"` + Refunded bool `json:"refunded,omitempty"` + Assigned bool `json:"assigned,omitempty"` + FromOffer bool `json:"from_offer,omitempty"` + Craft bool `json:"craft,omitempty"` + CanExportAt int `json:"can_export_at,omitempty"` + TransferStars int64 `json:"transfer_stars,omitempty"` + ResaleAmount *StarGiftAmount `json:"resale_amount,omitempty"` + CanTransferAt int `json:"can_transfer_at,omitempty"` + CanResellAt int `json:"can_resell_at,omitempty"` + DropOriginalDetailsStars int64 `json:"drop_original_details_stars,omitempty"` + CanCraftAt int `json:"can_craft_at,omitempty"` +} + +type MessageStarGiftOfferAction struct { + Gift UniqueStarGift `json:"gift"` + Price StarGiftAmount `json:"price"` + ExpiresAt int `json:"expires_at"` + Accepted bool `json:"accepted,omitempty"` + Declined bool `json:"declined,omitempty"` +} + +type MessageStarGiftOfferDeclinedAction struct { + Gift UniqueStarGift `json:"gift"` + Price StarGiftAmount `json:"price"` + Expired bool `json:"expired,omitempty"` } // MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。 diff --git a/internal/domain/star_gift.go b/internal/domain/star_gift.go index 3237d03f..4a1ec093 100644 --- a/internal/domain/star_gift.go +++ b/internal/domain/star_gift.go @@ -24,29 +24,85 @@ type StarGift struct { UpgradeIssued int // 当前已发行数量 Title string // 可选标题 Sticker Document // 礼物贴纸快照(tg 投影必须是带 sticker 属性的有效 Document,否则客户端丢弃) + + // Layer 228 regular-gift shape. Static release facts live in the immutable + // catalog revision; AvailabilityRemains/AvailabilityResale are the current + // inventory projection maintained on the catalog aggregate. + Limited bool + SoldOut bool + Birthday bool + RequirePremium bool + LimitedPerUser bool + PeerColorAvailable bool + Auction bool + AvailabilityRemains int + AvailabilityTotal int + AvailabilityResale int64 + FirstSaleDate int + LastSaleDate int + ResellMinStars int64 + ReleasedBy Peer + PerUserTotal int + PerUserRemains int + LockedUntilDate int + AuctionSlug string + GiftsPerRound int + AuctionStartDate int + UpgradeVariants int + Background *StarGiftBackground +} + +// StarGiftBackground is the release-level palette used by auction cards and +// gift previews before a collectible backdrop is selected. +type StarGiftBackground struct { + CenterColor int + EdgeColor int + TextColor int } // SavedStarGift 是一条已收到的礼物实例(peer_star_gifts 一行)。 type SavedStarGift struct { - ID int64 - Owner Peer // 收礼 peer(user/channel) - FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露) - GiftID int64 // → StarGift.ID - RevisionID int64 // → star_gift_catalog_revisions.id,历史查询必须按此版本投影 - MsgID int // 用户礼物的私聊 msg_id;频道礼物不进历史,固定为 0 - SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id;用户礼物为 0 - Date int // 收到时刻 Unix 秒 - NameHidden bool // 送礼人请求隐藏姓名 - Unsaved bool // 未展示在个人资料(saveStarGift 切换) - Converted bool // 已转换回 Stars(终态,从列表排除) - ConvertStars int64 // 转换可退回的 Stars - PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额 - Message string // 附言(可选) - UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥 - UpgradeMsgID int // messageActionStarGiftUnique 的 owner 侧消息 id - PinnedOrder int // >0 表示资料页置顶顺序 - CollectionIDs []int // 当前所属集合;按集合顺序稳定返回 - Unique *UniqueStarGift + ID int64 + Owner Peer // 收礼 peer(user/channel) + FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露) + GiftID int64 // → StarGift.ID + RevisionID int64 // → star_gift_catalog_revisions.id,历史查询必须按此版本投影 + MsgID int // 用户礼物的私聊 msg_id;频道礼物不进历史,固定为 0 + SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id;用户礼物为 0 + Date int // 收到时刻 Unix 秒 + NameHidden bool // 送礼人请求隐藏姓名 + Unsaved bool // 未展示在个人资料(saveStarGift 切换) + Converted bool // 已转换回 Stars(终态,从列表排除) + LifecycleStatus StarGiftLifecycleStatus + ConvertStars int64 // 转换可退回的 Stars + PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额 + PrepaidUpgradeHash string // 第三方单独代付升级的一次性 entitlement + GiftNum int // auction-acquired release number for regular gifts + Message string // 附言(可选) + UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥 + TransferStars int64 + CanExportAt int + CanTransferAt int + CanResellAt int + DropOriginalDetailsStars int64 + CanCraftAt int + UpgradeMsgID int // 当前 owner 侧承载 messageActionStarGiftUnique 的消息 id;所有权转移时随新消息更新 + PinnedOrder int // >0 表示资料页置顶顺序 + CollectionIDs []int // 当前所属集合;按集合顺序稳定返回 + Unique *UniqueStarGift +} + +type StarGiftLifecycleStatus string + +const ( + StarGiftLifecycleActive StarGiftLifecycleStatus = "active" + StarGiftLifecycleConverted StarGiftLifecycleStatus = "converted" + StarGiftLifecycleBurned StarGiftLifecycleStatus = "burned" + StarGiftLifecycleExported StarGiftLifecycleStatus = "exported" +) + +func (s StarGiftLifecycleStatus) Live() bool { + return s == StarGiftLifecycleActive } // StarGiftCollectibleAttributeKind 是唯一礼物三个必选属性槽位。 @@ -58,8 +114,31 @@ const ( StarGiftCollectibleBackdrop StarGiftCollectibleAttributeKind = "backdrop" ) -// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityPermille 同时是客户端展示的 -// 精确稀有度和升级抽取概率;同一 revision、同一 kind 的总和必须恰好为 1000。 +// StarGiftAttributeRarityKind mirrors the Layer 228 rarity union. Permille is the only +// kind eligible for a regular upgrade draw; named rarities are currently used by +// craft-only models and must still be preserved in the published attribute directory. +type StarGiftAttributeRarityKind string + +const ( + StarGiftRarityPermille StarGiftAttributeRarityKind = "permille" + StarGiftRarityUncommon StarGiftAttributeRarityKind = "uncommon" + StarGiftRarityRare StarGiftAttributeRarityKind = "rare" + StarGiftRarityEpic StarGiftAttributeRarityKind = "epic" + StarGiftRarityLegendary StarGiftAttributeRarityKind = "legendary" +) + +func (k StarGiftAttributeRarityKind) Valid() bool { + switch k { + case StarGiftRarityPermille, StarGiftRarityUncommon, StarGiftRarityRare, + StarGiftRarityEpic, StarGiftRarityLegendary: + return true + default: + return false + } +} + +// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityKind/RarityPermille +// 是客户端展示事实;普通升级把非 crafted 的 permille 值当相对权重,不要求合计为 1000。 type StarGiftCollectibleAttribute struct { ID int64 CollectibleRevisionID int64 @@ -71,7 +150,10 @@ type StarGiftCollectibleAttribute struct { EdgeColor int PatternColor int TextColor int + RarityKind StarGiftAttributeRarityKind RarityPermille int + Crafted bool + OfficialDocumentID int64 SortOrder int Animation *StarGiftAnimation Blob *FileBlob @@ -79,33 +161,37 @@ type StarGiftCollectibleAttribute struct { // StarGiftCollectibleRevision 是某普通礼物的一份不可变、可发布属性池。 type StarGiftCollectibleRevision struct { - ID int64 - GiftID int64 - Revision int - UpgradeStars int64 - SupplyTotal int - Issued int - SlugPrefix string - Published bool - Models []StarGiftCollectibleAttribute - Patterns []StarGiftCollectibleAttribute - Backdrops []StarGiftCollectibleAttribute - CreatedBy string - CreatedAt time.Time - PublishedAt time.Time + ID int64 + GiftID int64 + Revision int + UpgradeStars int64 + SupplyTotal int + Issued int + SlugPrefix string + Published bool + Models []StarGiftCollectibleAttribute + Patterns []StarGiftCollectibleAttribute + Backdrops []StarGiftCollectibleAttribute + CreatedBy string + CreatedAt time.Time + PublishedAt time.Time + OfficialGiftID int64 + SourceManifestSHA256 []byte } // StarGiftCollectibleWrite 是后台创建/发布属性池的协议无关输入。 type StarGiftCollectibleWrite struct { - GiftID int64 - UpgradeStars int64 - SupplyTotal int - SlugPrefix string - Models []StarGiftCollectibleAttribute - Patterns []StarGiftCollectibleAttribute - Backdrops []StarGiftCollectibleAttribute - Actor string - CommandID string + GiftID int64 + UpgradeStars int64 + SupplyTotal int + SlugPrefix string + Models []StarGiftCollectibleAttribute + Patterns []StarGiftCollectibleAttribute + Backdrops []StarGiftCollectibleAttribute + Actor string + CommandID string + OfficialGiftID int64 + SourceManifestSHA256 []byte } // UniqueStarGift 是一份已经发行的唯一礼物。属性、编号与 slug 一经创建永久不变。 @@ -118,6 +204,26 @@ type UniqueStarGift struct { Slug string Num int Owner Peer + RequirePremium bool + ResaleTonOnly bool + ThemeAvailable bool + Burned bool + Crafted bool + OwnerName string + OwnerAddress string + GiftAddress string + ResellAmount *StarGiftAmount + ResellVersion int64 + ReleasedBy Peer + ValueAmount int64 + ValueCurrency string + ValueUSD int64 + ThemePeer Peer + Host Peer + OfferMinStars int + CraftChancePermille int + LastSaleDate int + LastSaleAmount *StarGiftAmount Model StarGiftCollectibleAttribute Pattern StarGiftCollectibleAttribute Backdrop StarGiftCollectibleAttribute @@ -132,6 +238,33 @@ type UniqueStarGift struct { CreatedAt time.Time } +type StarGiftCurrency string + +const ( + StarGiftCurrencyStars StarGiftCurrency = "XTR" + StarGiftCurrencyTON StarGiftCurrency = "TON" +) + +type StarGiftAmount struct { + Currency StarGiftCurrency + Amount int64 + Nanos int +} + +func (a StarGiftAmount) Valid() bool { + if a.Amount <= 0 { + return false + } + switch a.Currency { + case StarGiftCurrencyStars: + return a.Nanos >= -999999999 && a.Nanos <= 999999999 + case StarGiftCurrencyTON: + return a.Nanos == 0 + default: + return false + } +} + // StarGiftUpgradePreview 是客户端升级弹窗所需的当前价格和属性样例。 type StarGiftUpgradePreview struct { GiftID int64 @@ -171,7 +304,205 @@ type StarGiftUpgradeRequest struct { OriginSessionID int64 } +type StarGiftPurchaseRequest struct { + BuyerUserID int64 + BuyerPremium bool + To Peer + GiftID int64 + RevisionID int64 + IncludeUpgrade bool + HideName bool + Message string + ChargeStars int64 + FormID int64 + CommandKey string + Date int + RecipientBlocked bool + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// StarGiftPurchaseForm is the server-issued, short-lived payment intent that +// binds payments.getPaymentForm to one later payments.sendStarsForm call. A +// fresh form represents a fresh purchase even when every invoice field is the +// same; retrying one form represents the same purchase command. +type StarGiftPurchaseForm struct { + FormID int64 + BuyerUserID int64 + To Peer + GiftID int64 + RevisionID int64 + IncludeUpgrade bool + HideName bool + Message string + ChargeStars int64 + IssuedAt int + ExpiresAt int +} + +type StarGiftPurchaseResult struct { + Gift StarGift + Saved SavedStarGift + Balance StarsBalance + Send SendPrivateTextResult + Duplicate bool +} + +// StarGiftConvertRequest identifies one owner-scoped regular gift conversion. +// ActorUserID is the authenticated user who owns the user gift or administers +// the channel gift; authorization is checked again at the RPC boundary. +type StarGiftConvertRequest struct { + ActorUserID int64 + Ref SavedStarGiftRef + Date int +} + +// StarGiftConvertResult exposes the committed aggregate state. OwnerBalance is +// the post-credit balance of either the user or the channel internal Stars +// ledger selected by Saved.Owner. +type StarGiftConvertResult struct { + Saved SavedStarGift + OwnerBalance int64 +} + type StarGiftUpgradeResult struct { + Saved SavedStarGift + Unique UniqueStarGift + Balance StarsBalance + Send SendPrivateTextResult + SourceEdits []EditedMessageForUser + Duplicate bool +} + +// StarGiftUpgradeReceipt is the immutable command envelope needed to replay a +// committed upgrade after the saved gift has entered its unique terminal state. +// In particular, a paid replay must not be rebound to a later catalog price. +type StarGiftUpgradeReceipt struct { + UserID int64 + SourceSavedGiftID int64 + FormID int64 + UniqueGiftID int64 + ChargeStars int64 + BalanceAfter int64 + SourceEditPts int + RequirePrepaid bool + KeepOriginalDetails bool +} + +type StarGiftPrepaidUpgradeRequest struct { + PayerUserID int64 + Owner Peer + Hash string + ChargeStars int64 + FormID int64 + CommandKey string + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftPrepaidUpgradeResult struct { + Saved SavedStarGift + Balance StarsBalance + Send SendPrivateTextResult + Duplicate bool +} + +type StarGiftDropOriginalDetailsRequest struct { + UserID int64 + Ref SavedStarGiftRef + ChargeStars int64 + FormID int64 + CommandKey string + Date int +} + +type StarGiftDropOriginalDetailsResult struct { + Saved SavedStarGift + Unique UniqueStarGift + Balance StarsBalance + Duplicate bool +} + +// StarGiftLifecyclePolicy is the server-owned policy snapshotted when a regular +// gift becomes collectible. It deliberately contains no wallet/node/provider +// configuration: TON remains only a currency unit in the local ledger. +type StarGiftLifecyclePolicy struct { + TransferStars int64 + DropOriginalDetailsStars int64 + OfferMinStars int + ExportDelaySeconds int + TransferDelaySeconds int + ResellDelaySeconds int + CraftDelaySeconds int + CraftChancePermille int +} + +func (p StarGiftLifecyclePolicy) Valid() bool { + return p.TransferStars >= 0 && p.DropOriginalDetailsStars >= 0 && p.OfferMinStars >= 0 && + p.ExportDelaySeconds >= 0 && p.TransferDelaySeconds >= 0 && p.ResellDelaySeconds >= 0 && + p.CraftDelaySeconds >= 0 && p.CraftChancePermille >= 0 && p.CraftChancePermille <= 1000 +} + +// StarGiftMarketPolicy is snapshotted in the running aggregate coordinator. +// Proceeds permille is the seller share; the remainder is recorded as platform +// commission. TON is still only a unit in the local ledger. +type StarGiftMarketPolicy struct { + StarsProceedsPermille int + TONProceedsPermille int +} + +func (p StarGiftMarketPolicy) Valid() bool { + return p.StarsProceedsPermille >= 0 && p.StarsProceedsPermille <= 1000 && + p.TONProceedsPermille >= 0 && p.TONProceedsPermille <= 1000 +} + +type StarGiftResaleFilter struct { + GiftID int64 + SortByPrice bool + SortByNum bool + ForCraft bool + StarsOnly bool + ModelIDs []int64 + PatternIDs []int64 + BackdropIDs []int64 + Offset string + Limit int +} + +type StarGiftResalePage struct { + Gifts []UniqueStarGift + Count int + NextOffset string +} + +type StarGiftValueInfo struct { + Currency string + Value int64 + ValueIsAverage bool + InitialSaleDate int + InitialSaleStars int64 + InitialSalePrice int64 + LastSaleDate int + LastSalePrice int64 + FloorPrice int64 + AveragePrice int64 + ListedCount int +} + +type StarGiftTransferRequest struct { + ActorUserID int64 + Ref SavedStarGiftRef + To Peer + ChargeStars int64 + FormID int64 + CommandKey string + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftTransferResult struct { Saved SavedStarGift Unique UniqueStarGift Balance StarsBalance @@ -179,6 +510,161 @@ type StarGiftUpgradeResult struct { Duplicate bool } +type StarGiftListingRequest struct { + ActorUserID int64 + Ref SavedStarGiftRef + Amount *StarGiftAmount + Date int +} + +type StarGiftResalePurchaseRequest struct { + BuyerUserID int64 + Slug string + To Peer + Amount StarGiftAmount + FormID int64 + CommandKey string + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftOfferRequest struct { + BuyerUserID int64 + Owner Peer + Slug string + Price StarGiftAmount + Duration int + RandomID int64 + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftOffer struct { + ID int64 + BuyerUserID int64 + Owner Peer + UniqueGiftID int64 + Price StarGiftAmount + RandomID int64 + OfferMsgID int + BuyerMsgID int + Status string + CreatedAt int + ExpiresAt int + ResolvedAt int + Gift UniqueStarGift +} + +type StarGiftOfferResult struct { + Offer StarGiftOffer + Saved SavedStarGift + Unique UniqueStarGift + Balance StarsBalance + Send SendPrivateTextResult + Duplicate bool +} + +type StarGiftResolveOfferRequest struct { + OwnerUserID int64 + OfferMsgID int + Decline bool + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftCraftRequest struct { + UserID int64 + Refs []SavedStarGiftRef + CommandKey string + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftCraftResult struct { + Success bool + Chance int + Gift *UniqueStarGift + Send SendPrivateTextResult + SourceEdits []EditedMessageForUser + Duplicate bool +} + +type StarGiftAuction struct { + Gift StarGift + Version int + StartDate int + EndDate int + MinBidAmount int64 + NextRoundAt int + LastGiftNum int + GiftsLeft int + CurrentRound int + TotalRounds int + RoundDuration int + BidLevels []StarGiftAuctionBidLevel + TopBidders []int64 + UserState StarGiftAuctionUserState + Finished bool + AveragePrice int64 + ListedCount int +} + +type StarGiftAuctionBidLevel struct { + Pos int + Amount int64 + Date int +} + +type StarGiftAuctionUserState struct { + Returned bool + BidAmount int64 + BidDate int + MinBidAmount int64 + BidPeer Peer + AcquiredCount int +} + +type StarGiftAuctionBidRequest struct { + UserID int64 + GiftID int64 + Peer Peer + BidAmount int64 + HideName bool + Message string + UpdateBid bool + FormID int64 + Date int +} + +type StarGiftAuctionAcquired struct { + Peer Peer + Date int + BidAmount int64 + Round int + Pos int + Message string + GiftNum int + NameHidden bool +} + +type StarGiftWithdrawalRequest struct { + UserID int64 + Ref SavedStarGiftRef + Date int +} + +type StarGiftWithdrawal struct { + ProviderRequestID string + URL string + ExpiresAt int + Status string + Gift UniqueStarGift +} + // StarGiftCollection 是 peer 资料页中的礼物集合;一份礼物可属于多个集合。 type StarGiftCollection struct { Owner Peer @@ -223,17 +709,53 @@ type StarGiftAnimation struct { // StarGiftCatalogWrite 是 store 原子创建目录版本所需的协议无关数据。 type StarGiftCatalogWrite struct { - GiftID int64 // 0 创建新礼物;非 0 为该礼物创建新 revision - Title string - Stars int64 - ConvertStars int64 - Enabled bool - SortOrder int - Document Document - Blob FileBlob - Animation StarGiftAnimation - Actor string - CommandID string + GiftID int64 // 0 创建新礼物;非 0 为该礼物创建新 revision + Title string + Stars int64 + ConvertStars int64 + Enabled bool + SortOrder int + Document Document + Blob FileBlob + Animation StarGiftAnimation + Actor string + CommandID string + OfficialGiftID int64 + SourceManifestSHA256 []byte + OfficialSourceJSON []byte + Limited bool + SoldOut bool + Birthday bool + RequirePremium bool + LimitedPerUser bool + PeerColorAvailable bool + Auction bool + AvailabilityRemains int + AvailabilityTotal int + AvailabilityResale int64 + FirstSaleDate int + LastSaleDate int + ResellMinStars int64 + ReleasedBy Peer + PerUserTotal int + LockedUntilDate int + AuctionSlug string + GiftsPerRound int + AuctionStartDate int + UpgradeVariants int + Background *StarGiftBackground +} + +// StarGiftCatalogBundleWrite atomically publishes one catalog revision and its optional +// complete collectible pool. Collectible.GiftID is filled with the allocated local gift ID. +type StarGiftCatalogBundleWrite struct { + Catalog StarGiftCatalogWrite + Collectible *StarGiftCollectibleWrite +} + +type StarGiftCatalogBundleResult struct { + Catalog StarGiftCatalogEntry + Collectible *StarGiftCollectibleRevision } // StarGiftCatalogEntry 是管理后台目录视图。 @@ -255,20 +777,24 @@ type StarGiftCatalogEntry struct { } // SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。 -// 用户礼物使用 inputSavedStarGiftUser.msg_id;频道礼物使用 inputSavedStarGiftChat.peer + saved_id。 +// 用户礼物使用 inputSavedStarGiftUser.msg_id;频道礼物使用 inputSavedStarGiftChat.peer + saved_id; +// 已升级的唯一礼物也可使用官方 inputSavedStarGiftSlug.slug。三种身份必须互斥。 type SavedStarGiftRef struct { Owner Peer MsgID int SavedID int64 + Slug string } // Valid reports whether the reference has the identity required by its owner kind. func (r SavedStarGiftRef) Valid() bool { + slug := strings.TrimSpace(r.Slug) + validSlug := slug != "" && slug == r.Slug && len(slug) <= MaxStarGiftSlugBytes && r.MsgID == 0 && r.SavedID == 0 switch r.Owner.Type { case PeerTypeUser: - return r.Owner.ID != 0 && r.MsgID > 0 + return r.Owner.ID != 0 && (validSlug || r.MsgID > 0 && r.SavedID == 0 && slug == "") case PeerTypeChannel: - return r.Owner.ID != 0 && r.SavedID > 0 + return r.Owner.ID != 0 && (validSlug || r.SavedID > 0 && r.MsgID == 0 && slug == "") default: return false } @@ -317,7 +843,8 @@ const ( // MaxStarGiftCatalogSize 是当前普通礼物目录的有界上限。 MaxStarGiftCatalogSize = 500 MaxStarGiftTitleRunes = 128 - MaxStarGiftCollectibleAttributesPerKind = 256 + MaxStarGiftSlugBytes = 255 + MaxStarGiftCollectibleAttributesPerKind = 512 MaxStarGiftCollectionTitleRunes = 12 MaxStarGiftCollectionsPerPeer = 100 MaxStarGiftCollectionItems = 1000 @@ -339,6 +866,18 @@ var ( ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition") ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found") ErrStarGiftCollectionsFull = errors.New("stargift: collections full") + ErrStarGiftUnavailable = errors.New("stargift: unavailable") + ErrStarGiftOwnerInvalid = errors.New("stargift: owner invalid") + ErrStarGiftTransferUnavailable = errors.New("stargift: transfer unavailable") + ErrStarGiftResaleUnavailable = errors.New("stargift: resale unavailable") + ErrStarGiftOfferInvalid = errors.New("stargift: offer invalid") + ErrStarGiftOfferExpired = errors.New("stargift: offer expired") + ErrStarGiftCraftUnavailable = errors.New("stargift: craft unavailable") + ErrStarGiftAuctionUnavailable = errors.New("stargift: auction unavailable") + ErrStarGiftWithdrawalUnavailable = errors.New("stargift: withdrawal provider unavailable") + ErrStarGiftFormExpired = errors.New("stargift: payment form expired") + ErrStarGiftFormPurposeInvalid = errors.New("stargift: payment form purpose invalid") + ErrStarGiftFormAmountMismatch = errors.New("stargift: payment form amount mismatch") ) var starGiftCollectibleSlugPrefix = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,47}$`) @@ -351,6 +890,11 @@ func ValidateStarGiftCollectibleDraft(write StarGiftCollectibleWrite) error { !starGiftCollectibleSlugPrefix.MatchString(write.SlugPrefix) || strings.TrimSpace(write.CommandID) == "" { return ErrStarGiftCollectibleInvalid } + if write.OfficialGiftID < 0 || + (write.OfficialGiftID == 0 && len(write.SourceManifestSHA256) != 0) || + (write.OfficialGiftID > 0 && len(write.SourceManifestSHA256) != 32) { + return ErrStarGiftCollectibleInvalid + } if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, false); err != nil { return err } @@ -380,11 +924,19 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind return ErrStarGiftCollectibleInvalid } seen := make(map[string]struct{}, len(attributes)) - total := 0 + selectable := 0 for _, attribute := range attributes { name := strings.TrimSpace(attribute.Name) - if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes || - attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 { + rarityKind := attribute.RarityKind + if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes || !rarityKind.Valid() { + return ErrStarGiftCollectibleInvalid + } + if rarityKind == StarGiftRarityPermille { + if attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 || attribute.Crafted { + return ErrStarGiftCollectibleInvalid + } + selectable++ + } else if attribute.RarityPermille != 0 || !attribute.Crafted || kind != StarGiftCollectibleModel { return ErrStarGiftCollectibleInvalid } key := strings.ToLower(name) @@ -392,7 +944,6 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind return ErrStarGiftCollectibleInvalid } seen[key] = struct{}{} - total += attribute.RarityPermille switch kind { case StarGiftCollectibleModel, StarGiftCollectiblePattern: if attribute.Animation == nil || len(attribute.Animation.JSON) == 0 || @@ -404,7 +955,7 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind return ErrStarGiftCollectibleInvalid } case StarGiftCollectibleBackdrop: - if attribute.BackdropID <= 0 || attribute.Document != nil || + if attribute.BackdropID < 0 || attribute.Document != nil || attribute.CenterColor < 0 || attribute.CenterColor > 0xffffff || attribute.EdgeColor < 0 || attribute.EdgeColor > 0xffffff || attribute.PatternColor < 0 || attribute.PatternColor > 0xffffff || @@ -415,7 +966,7 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind return ErrStarGiftCollectibleInvalid } } - if total != 1000 { + if selectable == 0 { return ErrStarGiftCollectibleInvalid } return nil diff --git a/internal/domain/star_gift_collectible_test.go b/internal/domain/star_gift_collectible_test.go new file mode 100644 index 00000000..82798de2 --- /dev/null +++ b/internal/domain/star_gift_collectible_test.go @@ -0,0 +1,95 @@ +package domain + +import ( + "crypto/sha256" + "errors" + "strings" + "testing" +) + +func TestSavedStarGiftRefRequiresOneOfficialIdentity(t *testing.T) { + user := Peer{Type: PeerTypeUser, ID: 42} + channel := Peer{Type: PeerTypeChannel, ID: 84} + tests := []struct { + name string + ref SavedStarGiftRef + want bool + }{ + {name: "user message", ref: SavedStarGiftRef{Owner: user, MsgID: 10}, want: true}, + {name: "channel saved id", ref: SavedStarGiftRef{Owner: channel, SavedID: 20}, want: true}, + {name: "user collectible slug", ref: SavedStarGiftRef{Owner: user, Slug: "official-42-1"}, want: true}, + {name: "channel collectible slug", ref: SavedStarGiftRef{Owner: channel, Slug: "official-84-1"}, want: true}, + {name: "message and slug", ref: SavedStarGiftRef{Owner: user, MsgID: 10, Slug: "official-42-1"}}, + {name: "saved id and slug", ref: SavedStarGiftRef{Owner: channel, SavedID: 20, Slug: "official-84-1"}}, + {name: "whitespace slug", ref: SavedStarGiftRef{Owner: user, Slug: " official-42-1"}}, + {name: "oversized slug", ref: SavedStarGiftRef{Owner: user, Slug: strings.Repeat("x", MaxStarGiftSlugBytes+1)}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.ref.Valid(); got != tt.want { + t.Fatalf("Valid() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestStarGiftLifecycleStatusRequiresExplicitActive(t *testing.T) { + if StarGiftLifecycleStatus("").Live() { + t.Fatal("empty lifecycle status must not be treated as active") + } + if !StarGiftLifecycleActive.Live() { + t.Fatal("active lifecycle status must be live") + } +} + +func validCollectibleDraft() StarGiftCollectibleWrite { + animation := &StarGiftAnimation{JSON: []byte(`{}`), TGS: []byte{1}, SHA256: make([]byte, sha256.Size)} + return StarGiftCollectibleWrite{ + GiftID: 1, UpgradeStars: 25, SupplyTotal: 100, SlugPrefix: "official-1", CommandID: "test", + Models: []StarGiftCollectibleAttribute{ + {Kind: StarGiftCollectibleModel, Name: "Regular", RarityKind: StarGiftRarityPermille, RarityPermille: 922, Animation: animation}, + {Kind: StarGiftCollectibleModel, Name: "Crafted", RarityKind: StarGiftRarityLegendary, Crafted: true, Animation: animation}, + }, + Patterns: []StarGiftCollectibleAttribute{ + {Kind: StarGiftCollectiblePattern, Name: "Pattern", RarityKind: StarGiftRarityPermille, RarityPermille: 989, Animation: animation}, + }, + Backdrops: []StarGiftCollectibleAttribute{ + {Kind: StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 0, RarityKind: StarGiftRarityPermille, RarityPermille: 999}, + }, + } +} + +func TestValidateStarGiftCollectibleDraftOfficialProvenance(t *testing.T) { + write := validCollectibleDraft() + write.OfficialGiftID = 10 + write.SourceManifestSHA256 = make([]byte, sha256.Size) + if err := ValidateStarGiftCollectibleDraft(write); err != nil { + t.Fatalf("valid official draft: %v", err) + } + + tests := map[string]StarGiftCollectibleWrite{} + withoutHash := write + withoutHash.SourceManifestSHA256 = nil + tests["official ID without hash"] = withoutHash + withoutID := write + withoutID.OfficialGiftID = 0 + tests["hash without official ID"] = withoutID + negativeID := write + negativeID.OfficialGiftID = -1 + tests["negative official ID"] = negativeID + for name, invalid := range tests { + t.Run(name, func(t *testing.T) { + if err := ValidateStarGiftCollectibleDraft(invalid); !errors.Is(err, ErrStarGiftCollectibleInvalid) { + t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err) + } + }) + } +} + +func TestValidateStarGiftCollectibleDraftRejectsImplicitRarity(t *testing.T) { + write := validCollectibleDraft() + write.Models[0].RarityKind = "" + if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) { + t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err) + } +} diff --git a/internal/domain/stars.go b/internal/domain/stars.go index 383a188d..84362af4 100644 --- a/internal/domain/stars.go +++ b/internal/domain/stars.go @@ -20,13 +20,19 @@ type StarsBalance struct { type StarsTransactionReason string const ( - StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予 - StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造) - StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费 - StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取 - StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物 - StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁 - StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整 + StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予 + StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造) + StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费 + StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取 + StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物 + StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer" + StarsReasonGiftResale StarsTransactionReason = "gift_resale" + StarsReasonGiftOffer StarsTransactionReason = "gift_offer" + StarsReasonGiftAuction StarsTransactionReason = "gift_auction" + StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade" + StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details" + StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁 + StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整 ) // StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。 @@ -52,6 +58,28 @@ type StarsTransactionPage struct { Users []User // History 中提到的对手方用户,供 tg Users 富化 } +// TonTransaction is an entry in telesrv's internal nanoton ledger. It models +// the Telegram TON-denominated gift UI without contacting a wallet, Fragment, +// a TON node, or any blockchain service. +type TonTransaction struct { + ID int64 + UserID int64 + Peer Peer + GiftID int64 + Amount int64 // signed nanoton amount + Date int + Reason StarsTransactionReason + Title string + Description string +} + +type TonTransactionPage struct { + Balance int64 + Transactions []TonTransaction + NextOffset string + Users []User +} + // Stars 账本边界常量。 const ( // DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。 diff --git a/internal/officialgifts/catalog.go b/internal/officialgifts/catalog.go new file mode 100644 index 00000000..50c5d38d --- /dev/null +++ b/internal/officialgifts/catalog.go @@ -0,0 +1,531 @@ +// Package officialgifts reads the local, immutable snapshot produced by cmd/giftfetch. +// It never performs network I/O. Selected document bytes are accepted only after their +// manifest size and SHA-256 have been verified beneath the configured snapshot root. +package officialgifts + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +const manifestSchema = 2 + +var ( + ErrUnavailable = errors.New("official gifts snapshot is unavailable") + ErrInvalid = errors.New("official gifts snapshot is invalid") + ErrNotFound = errors.New("official gift not found") +) + +type Catalog struct { + root string + once sync.Once + snap *snapshot + err error +} + +type GiftSummary struct { + ID int64 + Title string + Stars int64 + ConvertStars int64 + UpgradeStars int64 + AvailabilityTotal int + AvailabilityRemains int + AvailabilityResale int64 + Limited bool + SoldOut bool + Birthday bool + RequirePremium bool + LimitedPerUser bool + PeerColorAvailable bool + Auction bool + FirstSaleDate int + LastSaleDate int + ResellMinStars int64 + PerUserTotal int + PerUserRemains int + LockedUntilDate int + AuctionSlug string + GiftsPerRound int + AuctionStartDate int + UpgradeVariants int + Background *Background + ModelCount int + PatternCount int + BackdropCount int + CraftedModelCount int + DocumentID int64 + AnimationValidated bool +} + +// CanUpgrade reports whether this snapshot contains the complete immutable +// attribute pool and a positive official upgrade price required to mint a +// collectible from the regular gift. +func (g GiftSummary) CanUpgrade() bool { + return g.UpgradeStars > 0 && g.ModelCount > 0 && g.PatternCount > 0 && g.BackdropCount > 0 +} + +// CanCraft reports whether upgraded collectibles from this gift can reach an +// official craft-only model. Craft is not advertised without a valid upgrade +// path even if a malformed snapshot were to contain a crafted model. +func (g GiftSummary) CanCraft() bool { + return g.CanUpgrade() && g.CraftedModelCount > 0 +} + +type Bundle struct { + ManifestSHA256 []byte + SourceJSON []byte + Gift Gift + BaseDocument Document + Collectible *CollectibleSet +} + +type Gift struct { + ID int64 + Title string + Stars int64 + ConvertStars int64 + UpgradeStars int64 + AvailabilityTotal int + AvailabilityRemains int + Limited bool + SoldOut bool + Birthday bool + RequirePremium bool + LimitedPerUser bool + PeerColorAvailable bool + Auction bool + AvailabilityResale int64 + FirstSaleDate int + LastSaleDate int + ResellMinStars int64 + PerUserTotal int + PerUserRemains int + LockedUntilDate int + AuctionSlug string + GiftsPerRound int + AuctionStartDate int + UpgradeVariants int + Background *Background + DocumentID int64 +} + +type Background struct { + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + TextColor int `json:"text_color"` +} + +type CollectibleSet struct { + Models []Model + Patterns []Pattern + Backdrops []Backdrop +} + +type Rarity struct { + Kind string `json:"kind"` + Permille *int `json:"permille,omitempty"` +} + +type Model struct { + Name string + DocumentID int64 + Crafted bool + Rarity Rarity + Document Document +} + +type Pattern struct { + Name string + DocumentID int64 + Rarity Rarity + Document Document +} + +type Backdrop struct { + Name string + BackdropID int + CenterColor int + EdgeColor int + PatternColor int + TextColor int + Rarity Rarity +} + +type Document struct { + ID int64 + FileName string + Path string + Size int64 + SHA256 string + AnimationValidated bool + ValidationError string + Data []byte +} + +type manifest struct { + Schema int `json:"schema"` + GiftCount int `json:"gift_count"` + Gifts []giftManifest `json:"gifts"` + UpgradeAttributeSets []collectibleManifest `json:"upgrade_attribute_sets"` + Documents []documentManifest `json:"documents"` +} + +type giftManifest struct { + Index int `json:"index"` + Kind string `json:"kind"` + ID int64 `json:"id"` + Title string `json:"title"` + Stars int64 `json:"stars"` + ConvertStars int64 `json:"convert_stars"` + UpgradeStars int64 `json:"upgrade_stars"` + Limited bool `json:"limited"` + SoldOut bool `json:"sold_out"` + Birthday bool `json:"birthday"` + RequirePremium bool `json:"require_premium"` + LimitedPerUser bool `json:"limited_per_user"` + PeerColorAvailable bool `json:"peer_color_available"` + Auction bool `json:"auction"` + AvailabilityRemains int `json:"availability_remains"` + AvailabilityTotal int `json:"availability_total"` + AvailabilityResale int64 `json:"availability_resale"` + FirstSaleDate int `json:"first_sale_date"` + LastSaleDate int `json:"last_sale_date"` + ResellMinStars int64 `json:"resell_min_stars"` + PerUserTotal int `json:"per_user_total"` + PerUserRemains int `json:"per_user_remains"` + LockedUntilDate int `json:"locked_until_date"` + AuctionSlug string `json:"auction_slug"` + GiftsPerRound int `json:"gifts_per_round"` + AuctionStartDate int `json:"auction_start_date"` + UpgradeVariants int `json:"upgrade_variants"` + Background *Background `json:"background"` + DocumentIDs []int64 `json:"document_ids"` + SourceJSON []byte `json:"-"` +} + +func (g *giftManifest) UnmarshalJSON(data []byte) error { + type plain giftManifest + var value plain + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *g = giftManifest(value) + var compact bytes.Buffer + if err := json.Compact(&compact, data); err != nil { + return err + } + g.SourceJSON = append([]byte(nil), compact.Bytes()...) + return nil +} + +type collectibleManifest struct { + GiftID64 int64 `json:"gift_id"` + AttributeCount int `json:"attribute_count"` + Models []modelManifest `json:"models"` + Patterns []patternManifest `json:"patterns"` + Backdrops []backdropManifest `json:"backdrops"` +} + +type modelManifest struct { + Name string `json:"name"` + DocumentID int64 `json:"document_id"` + Crafted bool `json:"crafted"` + Rarity Rarity `json:"rarity"` +} + +type patternManifest struct { + Name string `json:"name"` + DocumentID int64 `json:"document_id"` + Rarity Rarity `json:"rarity"` +} + +type backdropManifest struct { + Name string `json:"name"` + BackdropID int `json:"backdrop_id"` + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + PatternColor int `json:"pattern_color"` + TextColor int `json:"text_color"` + Rarity Rarity `json:"rarity"` +} + +type documentManifest struct { + ID64 int64 `json:"id"` + FileName string `json:"file_name"` + File fileArtifact `json:"file"` + AnimationValidated bool `json:"animation_validated"` + ValidationError string `json:"validation_error"` +} + +type fileArtifact struct { + Path string `json:"path"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type snapshot struct { + manifestSHA []byte + gifts map[int64]giftManifest + sets map[int64]collectibleManifest + documents map[int64]documentManifest + ordered []int64 +} + +func New(root string) *Catalog { + return &Catalog{root: strings.TrimSpace(root)} +} + +func (c *Catalog) List(ctx context.Context) ([]GiftSummary, error) { + snap, err := c.load() + if err != nil { + return nil, err + } + out := make([]GiftSummary, 0, len(snap.ordered)) + for _, id := range snap.ordered { + if err := ctx.Err(); err != nil { + return nil, err + } + gift := snap.gifts[id] + doc := snap.documents[gift.DocumentIDs[0]] + summary := GiftSummary{ + ID: id, Title: gift.Title, Stars: gift.Stars, ConvertStars: gift.ConvertStars, + UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal, + AvailabilityRemains: gift.AvailabilityRemains, AvailabilityResale: gift.AvailabilityResale, + Limited: gift.Limited, SoldOut: gift.SoldOut, Birthday: gift.Birthday, + RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser, + PeerColorAvailable: gift.PeerColorAvailable, Auction: gift.Auction, + FirstSaleDate: gift.FirstSaleDate, LastSaleDate: gift.LastSaleDate, + ResellMinStars: gift.ResellMinStars, PerUserTotal: gift.PerUserTotal, + PerUserRemains: gift.PerUserRemains, LockedUntilDate: gift.LockedUntilDate, + AuctionSlug: gift.AuctionSlug, GiftsPerRound: gift.GiftsPerRound, + AuctionStartDate: gift.AuctionStartDate, UpgradeVariants: gift.UpgradeVariants, + Background: cloneBackground(gift.Background), DocumentID: doc.ID64, + AnimationValidated: doc.AnimationValidated, + } + if set, ok := snap.sets[id]; ok { + summary.ModelCount, summary.PatternCount, summary.BackdropCount = len(set.Models), len(set.Patterns), len(set.Backdrops) + for _, model := range set.Models { + if model.Crafted { + summary.CraftedModelCount++ + } + } + } + out = append(out, summary) + } + return out, nil +} + +func (c *Catalog) Bundle(ctx context.Context, giftID int64, includeCollectible bool) (Bundle, error) { + snap, err := c.load() + if err != nil { + return Bundle{}, err + } + gift, ok := snap.gifts[giftID] + if !ok { + return Bundle{}, ErrNotFound + } + base, err := c.readDocument(ctx, snap.documents[gift.DocumentIDs[0]]) + if err != nil { + return Bundle{}, fmt.Errorf("base document %d: %w", gift.DocumentIDs[0], err) + } + out := Bundle{ + ManifestSHA256: append([]byte(nil), snap.manifestSHA...), + SourceJSON: append([]byte(nil), gift.SourceJSON...), + Gift: Gift{ID: gift.ID, Title: gift.Title, Stars: gift.Stars, ConvertStars: gift.ConvertStars, + UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal, + AvailabilityRemains: gift.AvailabilityRemains, Limited: gift.Limited, SoldOut: gift.SoldOut, + Birthday: gift.Birthday, RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser, + PeerColorAvailable: gift.PeerColorAvailable, Auction: gift.Auction, + AvailabilityResale: gift.AvailabilityResale, FirstSaleDate: gift.FirstSaleDate, + LastSaleDate: gift.LastSaleDate, ResellMinStars: gift.ResellMinStars, + PerUserTotal: gift.PerUserTotal, PerUserRemains: gift.PerUserRemains, + LockedUntilDate: gift.LockedUntilDate, AuctionSlug: gift.AuctionSlug, + GiftsPerRound: gift.GiftsPerRound, AuctionStartDate: gift.AuctionStartDate, + UpgradeVariants: gift.UpgradeVariants, Background: cloneBackground(gift.Background), + DocumentID: gift.DocumentIDs[0]}, + BaseDocument: base, + } + if !includeCollectible { + return out, nil + } + set, ok := snap.sets[giftID] + if !ok { + return Bundle{}, fmt.Errorf("%w: gift %d has no collectible attribute set", ErrInvalid, giftID) + } + collectible := &CollectibleSet{ + Models: make([]Model, 0, len(set.Models)), Patterns: make([]Pattern, 0, len(set.Patterns)), + Backdrops: make([]Backdrop, 0, len(set.Backdrops)), + } + for _, value := range set.Models { + doc, err := c.readDocument(ctx, snap.documents[value.DocumentID]) + if err != nil { + return Bundle{}, fmt.Errorf("model %q document %d: %w", value.Name, value.DocumentID, err) + } + collectible.Models = append(collectible.Models, Model{Name: value.Name, DocumentID: value.DocumentID, Crafted: value.Crafted, Rarity: value.Rarity, Document: doc}) + } + for _, value := range set.Patterns { + doc, err := c.readDocument(ctx, snap.documents[value.DocumentID]) + if err != nil { + return Bundle{}, fmt.Errorf("pattern %q document %d: %w", value.Name, value.DocumentID, err) + } + collectible.Patterns = append(collectible.Patterns, Pattern{Name: value.Name, DocumentID: value.DocumentID, Rarity: value.Rarity, Document: doc}) + } + for _, value := range set.Backdrops { + collectible.Backdrops = append(collectible.Backdrops, Backdrop{Name: value.Name, BackdropID: value.BackdropID, + CenterColor: value.CenterColor, EdgeColor: value.EdgeColor, PatternColor: value.PatternColor, + TextColor: value.TextColor, Rarity: value.Rarity}) + } + out.Collectible = collectible + return out, nil +} + +func cloneBackground(value *Background) *Background { + if value == nil { + return nil + } + copy := *value + return © +} + +func (c *Catalog) load() (*snapshot, error) { + c.once.Do(func() { + if c.root == "" { + c.err = ErrUnavailable + return + } + manifestPath := filepath.Join(c.root, "manifest.json") + raw, err := os.ReadFile(manifestPath) + if err != nil { + c.err = fmt.Errorf("%w: %v", ErrUnavailable, err) + return + } + var value manifest + decoder := json.NewDecoder(bytes.NewReader(raw)) + if err := decoder.Decode(&value); err != nil { + c.err = fmt.Errorf("%w: decode manifest: %v", ErrInvalid, err) + return + } + if value.Schema != manifestSchema || value.GiftCount != len(value.Gifts) || len(value.Gifts) == 0 { + c.err = fmt.Errorf("%w: unexpected schema or gift count", ErrInvalid) + return + } + sum := sha256.Sum256(raw) + snap := &snapshot{manifestSHA: append([]byte(nil), sum[:]...), gifts: make(map[int64]giftManifest, len(value.Gifts)), + sets: make(map[int64]collectibleManifest, len(value.UpgradeAttributeSets)), documents: make(map[int64]documentManifest, len(value.Documents))} + for _, doc := range value.Documents { + if doc.ID64 <= 0 || doc.File.Size <= 0 || len(doc.File.SHA256) != 64 || strings.TrimSpace(doc.File.Path) == "" { + c.err = fmt.Errorf("%w: invalid document %d", ErrInvalid, doc.ID64) + return + } + if _, duplicate := snap.documents[doc.ID64]; duplicate { + c.err = fmt.Errorf("%w: duplicate document %d", ErrInvalid, doc.ID64) + return + } + snap.documents[doc.ID64] = doc + } + for _, gift := range value.Gifts { + if gift.Kind != "regular" || gift.ID <= 0 || gift.Stars <= 0 || len(gift.DocumentIDs) != 1 { + c.err = fmt.Errorf("%w: invalid gift %d", ErrInvalid, gift.ID) + return + } + if _, ok := snap.documents[gift.DocumentIDs[0]]; !ok { + c.err = fmt.Errorf("%w: gift %d document missing", ErrInvalid, gift.ID) + return + } + if _, duplicate := snap.gifts[gift.ID]; duplicate { + c.err = fmt.Errorf("%w: duplicate gift %d", ErrInvalid, gift.ID) + return + } + snap.gifts[gift.ID] = gift + snap.ordered = append(snap.ordered, gift.ID) + } + for _, set := range value.UpgradeAttributeSets { + if _, ok := snap.gifts[set.GiftID64]; !ok || set.AttributeCount != len(set.Models)+len(set.Patterns)+len(set.Backdrops) || + len(set.Models) == 0 || len(set.Patterns) == 0 || len(set.Backdrops) == 0 { + c.err = fmt.Errorf("%w: invalid collectible set %d", ErrInvalid, set.GiftID64) + return + } + if _, duplicate := snap.sets[set.GiftID64]; duplicate { + c.err = fmt.Errorf("%w: duplicate collectible set %d", ErrInvalid, set.GiftID64) + return + } + for _, model := range set.Models { + if _, ok := snap.documents[model.DocumentID]; !ok || !validRarity(model.Rarity, model.Crafted) { + c.err = fmt.Errorf("%w: invalid model %q", ErrInvalid, model.Name) + return + } + } + for _, pattern := range set.Patterns { + if _, ok := snap.documents[pattern.DocumentID]; !ok || !validRarity(pattern.Rarity, false) { + c.err = fmt.Errorf("%w: invalid pattern %q", ErrInvalid, pattern.Name) + return + } + } + for _, backdrop := range set.Backdrops { + if backdrop.BackdropID < 0 || !validRarity(backdrop.Rarity, false) { + c.err = fmt.Errorf("%w: invalid backdrop %q", ErrInvalid, backdrop.Name) + return + } + } + snap.sets[set.GiftID64] = set + } + sort.SliceStable(snap.ordered, func(i, j int) bool { return snap.gifts[snap.ordered[i]].Index < snap.gifts[snap.ordered[j]].Index }) + c.snap = snap + }) + return c.snap, c.err +} + +func validRarity(rarity Rarity, crafted bool) bool { + if rarity.Kind == "permille" { + return !crafted && rarity.Permille != nil && *rarity.Permille > 0 && *rarity.Permille <= 1000 + } + return crafted && rarity.Permille == nil && (rarity.Kind == "uncommon" || rarity.Kind == "rare" || rarity.Kind == "epic" || rarity.Kind == "legendary") +} + +func (c *Catalog) readDocument(ctx context.Context, doc documentManifest) (Document, error) { + if err := ctx.Err(); err != nil { + return Document{}, err + } + root, err := filepath.Abs(c.root) + if err != nil { + return Document{}, err + } + clean := filepath.Clean(filepath.FromSlash(doc.File.Path)) + if filepath.IsAbs(clean) { + return Document{}, ErrInvalid + } + full := filepath.Join(root, clean) + rel, err := filepath.Rel(root, full) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return Document{}, ErrInvalid + } + data, err := os.ReadFile(full) + if err != nil { + return Document{}, err + } + if int64(len(data)) != doc.File.Size { + return Document{}, fmt.Errorf("%w: size mismatch", ErrInvalid) + } + sum := sha256.Sum256(data) + if !strings.EqualFold(hex.EncodeToString(sum[:]), doc.File.SHA256) { + return Document{}, fmt.Errorf("%w: sha256 mismatch", ErrInvalid) + } + name := strings.TrimSpace(doc.FileName) + if name == "" { + name = filepath.Base(clean) + } + return Document{ID: doc.ID64, FileName: name, Path: doc.File.Path, Size: doc.File.Size, + SHA256: strings.ToLower(doc.File.SHA256), AnimationValidated: doc.AnimationValidated, + ValidationError: doc.ValidationError, Data: data}, nil +} diff --git a/internal/officialgifts/catalog_test.go b/internal/officialgifts/catalog_test.go new file mode 100644 index 00000000..785f191e --- /dev/null +++ b/internal/officialgifts/catalog_test.go @@ -0,0 +1,119 @@ +package officialgifts + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestCatalogVerifiesSelectedDocument(t *testing.T) { + root := t.TempDir() + data := []byte("official-tgs") + sum := sha256.Sum256(data) + if err := os.MkdirAll(filepath.Join(root, "documents"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), data, 0o644); err != nil { + t.Fatal(err) + } + value := manifest{Schema: manifestSchema, GiftCount: 1, + Gifts: []giftManifest{{Index: 0, Kind: "regular", ID: 1, Stars: 10, ConvertStars: 5, DocumentIDs: []int64{10}}}, + Documents: []documentManifest{{ID64: 10, FileName: "gift.tgs", File: fileArtifact{Path: "documents/10.tgs", Size: int64(len(data)), SHA256: hex.EncodeToString(sum[:])}}}, + } + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "manifest.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + catalog := New(root) + items, err := catalog.List(context.Background()) + if err != nil || len(items) != 1 || items[0].ID != 1 { + t.Fatalf("items=%+v err=%v", items, err) + } + bundle, err := catalog.Bundle(context.Background(), 1, false) + if err != nil || string(bundle.BaseDocument.Data) != string(data) { + t.Fatalf("bundle=%+v err=%v", bundle, err) + } + if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), []byte("tampered---"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := catalog.Bundle(context.Background(), 1, false); err == nil { + t.Fatal("tampered document was accepted") + } +} + +func TestConfiguredOfficialSnapshotIsComplete(t *testing.T) { + root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR") + if root == "" { + t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set") + } + catalog := New(root) + items, err := catalog.List(context.Background()) + if err != nil { + t.Fatal(err) + } + var sets, attributes, crafted, upgradable, craftable int + verifiedDocuments := map[int64]struct{}{} + for _, item := range items { + if item.CanUpgrade() { + upgradable++ + } + if item.CanCraft() { + craftable++ + } + include := item.ModelCount+item.PatternCount+item.BackdropCount > 0 + bundle, err := catalog.Bundle(context.Background(), item.ID, include) + if err != nil { + t.Fatalf("gift %d: %v", item.ID, err) + } + verifiedDocuments[bundle.BaseDocument.ID] = struct{}{} + if bundle.Collectible == nil { + continue + } + sets++ + attributes += len(bundle.Collectible.Models) + len(bundle.Collectible.Patterns) + len(bundle.Collectible.Backdrops) + for _, model := range bundle.Collectible.Models { + verifiedDocuments[model.Document.ID] = struct{}{} + if model.Crafted { + crafted++ + } + } + for _, pattern := range bundle.Collectible.Patterns { + verifiedDocuments[pattern.Document.ID] = struct{}{} + } + } + if len(items) != 149 || sets != 116 || attributes != 40332 || crafted != 108 || upgradable != 114 || craftable != 2 || len(verifiedDocuments) != 8333 { + t.Fatalf("gifts=%d sets=%d attributes=%d crafted=%d upgradable=%d craftable=%d documents=%d", + len(items), sets, attributes, crafted, upgradable, craftable, len(verifiedDocuments)) + } +} + +func TestGiftSummaryCapabilitiesRequireCompleteOfficialFacts(t *testing.T) { + complete := GiftSummary{UpgradeStars: 25, ModelCount: 1, PatternCount: 1, BackdropCount: 1} + if !complete.CanUpgrade() || complete.CanCraft() { + t.Fatalf("complete regular pool capabilities = upgrade:%v craft:%v", complete.CanUpgrade(), complete.CanCraft()) + } + craftable := complete + craftable.CraftedModelCount = 1 + if !craftable.CanUpgrade() || !craftable.CanCraft() { + t.Fatalf("crafted pool capabilities = upgrade:%v craft:%v", craftable.CanUpgrade(), craftable.CanCraft()) + } + for name, invalid := range map[string]GiftSummary{ + "zero upgrade price": {ModelCount: 1, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1}, + "missing model": {UpgradeStars: 25, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1}, + "missing pattern": {UpgradeStars: 25, ModelCount: 1, BackdropCount: 1, CraftedModelCount: 1}, + "missing backdrop": {UpgradeStars: 25, ModelCount: 1, PatternCount: 1, CraftedModelCount: 1}, + } { + t.Run(name, func(t *testing.T) { + if invalid.CanUpgrade() || invalid.CanCraft() { + t.Fatalf("invalid facts advertised capabilities: %+v", invalid) + } + }) + } +} diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index bbc5e649..a53cc0a5 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -284,6 +284,8 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction } case domain.ChannelActionStarGift: return tgMessageActionStarGift(action.StarGift) + case domain.ChannelActionStarGiftUnique: + return tgMessageActionStarGiftUnique(action.StarGiftUnique) case domain.ChannelActionSetChatWallpaper: if wallpaper := tgWallpaper(action.Wallpaper); wallpaper != nil { return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper} diff --git a/internal/rpc/convert_messages.go b/internal/rpc/convert_messages.go index 1e119b35..a24e7883 100644 --- a/internal/rpc/convert_messages.go +++ b/internal/rpc/convert_messages.go @@ -221,29 +221,69 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass { case domain.MessageServiceActionStarGift: return tgMessageActionStarGift(m.ServiceAction.StarGift) case domain.MessageServiceActionStarGiftUnique: - action := m.ServiceAction.StarGiftUnique + return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique) + case domain.MessageServiceActionStarGiftOffer: + action := m.ServiceAction.StarGiftOffer if action == nil { return &tg.MessageActionEmpty{} } - out := &tg.MessageActionStarGiftUnique{ - Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade, - Gift: tgUniqueStarGift(action.Gift), + return &tg.MessageActionStarGiftPurchaseOffer{Accepted: action.Accepted, Declined: action.Declined, + Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price), ExpiresAt: action.ExpiresAt} + case domain.MessageServiceActionStarGiftOfferDeclined: + action := m.ServiceAction.StarGiftOfferDeclined + if action == nil { + return &tg.MessageActionEmpty{} } - if action.FromUserID != 0 { - out.SetFromID(&tg.PeerUser{UserID: action.FromUserID}) - } - if peer := tgPeer(action.Peer); peer != nil { - out.SetPeer(peer) - } - if action.SavedID != 0 { - out.SetSavedID(action.SavedID) - } - return out + return &tg.MessageActionStarGiftPurchaseOfferDeclined{Expired: action.Expired, + Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price)} default: return &tg.MessageActionEmpty{} } } +func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) tg.MessageActionClass { + if action == nil { + return &tg.MessageActionEmpty{} + } + out := &tg.MessageActionStarGiftUnique{ + Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade, + Transferred: action.Transferred, Refunded: action.Refunded, Assigned: action.Assigned, + FromOffer: action.FromOffer, Craft: action.Craft, + Gift: tgUniqueStarGift(action.Gift), + } + if action.CanExportAt > 0 { + out.SetCanExportAt(action.CanExportAt) + } + if action.TransferStars > 0 { + out.SetTransferStars(action.TransferStars) + } + if action.ResaleAmount != nil { + out.SetResaleAmount(tgStarGiftAmount(*action.ResaleAmount)) + } + if action.CanTransferAt > 0 { + out.SetCanTransferAt(action.CanTransferAt) + } + if action.CanResellAt > 0 { + out.SetCanResellAt(action.CanResellAt) + } + if action.DropOriginalDetailsStars > 0 { + out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars) + } + if action.CanCraftAt > 0 { + out.SetCanCraftAt(action.CanCraftAt) + } + if action.FromUserID != 0 { + out.SetFromID(&tg.PeerUser{UserID: action.FromUserID}) + } + if peer := tgPeer(action.Peer); peer != nil { + out.SetPeer(peer) + } + if action.SavedID != 0 { + out.SetSavedID(action.SavedID) + } + return out +} + func tgPeerList(peers []domain.Peer) []tg.PeerClass { out := make([]tg.PeerClass, 0, len(peers)) for _, peer := range peers { diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index eefaa89c..2185c832 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -872,6 +872,7 @@ type GiftsService interface { UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) + UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) @@ -879,13 +880,36 @@ type GiftsService interface { ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) CountSaved(ctx context.Context, owner domain.Peer) (int, error) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) - Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) + ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error + ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) + ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) + SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) + Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) + PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) + SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) + ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) + ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) + Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) + AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error) + ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) + AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) + BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) + PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) + PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) + DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) + SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error + Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) + TonBalance(ctx context.Context, userID int64) (int64, error) + TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) + IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) + ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error + Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) } // StarsService 抽象 Stars 本地账本(app/stars):余额查询、贷记/借记、流水分页。 diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 3fcdd290..365cf7d2 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -137,6 +137,10 @@ func starsFormAmountMismatchErr() error { return tgerr.New(406, "STARS_FORM_AMOU func formIDEmptyErr() error { return tgerr.New(400, "FORM_ID_EMPTY") } +func formExpiredErr() error { return tgerr.New(400, "FORM_EXPIRED") } + +func purposeInvalidErr() error { return tgerr.New(400, "PURPOSE_INVALID") } + func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") } func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") } diff --git a/internal/rpc/payments.go b/internal/rpc/payments.go index 378821ee..51895422 100644 --- a/internal/rpc/payments.go +++ b/internal/rpc/payments.go @@ -33,12 +33,11 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { registerRPC[*tg.PaymentsGetStarsTransactionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTransactions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTransactionsRequest) (any, error) { return r.onPaymentsGetStarsTransactions(ctx, layerRequest) }) + registerRPC[*tg.PaymentsCheckCanSendGiftRequest](d, tlprofile.SemanticMethodPaymentsCheckCanSendGift, func(ctx context.Context, req *tg.PaymentsCheckCanSendGiftRequest) (any, error) { + return r.onPaymentsCheckCanSendGift(ctx, req) + }) registerRPC[*tg.PaymentsGetStarGiftActiveAuctionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftActiveAuctions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftActiveAuctionsRequest) (any, error) { - hash := layerRequest. - Hash - _ = hash - - return tdesktop.StarGiftActiveAuctions(), nil + return r.onPaymentsGetStarGiftActiveAuctions(ctx, layerRequest) }) registerRPC[*tg.PaymentsGetStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftsRequest) (any, error) { return r.onPaymentsGetStarGifts(ctx, layerRequest. @@ -48,10 +47,19 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { return r.onPaymentsGetStarGiftUpgradePreview(ctx, layerRequest. GiftID) }) + registerRPC[*tg.PaymentsGetStarGiftUpgradeAttributesRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftUpgradeAttributes, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftUpgradeAttributesRequest) (any, error) { + return r.onPaymentsGetStarGiftUpgradeAttributes(ctx, layerRequest.GiftID) + }) registerRPC[*tg.PaymentsGetUniqueStarGiftRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGift, func(ctx context.Context, layerRequest *tg.PaymentsGetUniqueStarGiftRequest) (any, error) { return r.onPaymentsGetUniqueStarGift(ctx, layerRequest. Slug) }) + registerRPC[*tg.PaymentsGetUniqueStarGiftValueInfoRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGiftValueInfo, func(ctx context.Context, req *tg.PaymentsGetUniqueStarGiftValueInfoRequest) (any, error) { + return r.onPaymentsGetUniqueStarGiftValueInfo(ctx, req) + }) + registerRPC[*tg.PaymentsGetResaleStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetResaleStarGifts, func(ctx context.Context, req *tg.PaymentsGetResaleStarGiftsRequest) (any, error) { + return r.onPaymentsGetResaleStarGifts(ctx, req) + }) registerRPC[*tg.PaymentsGetPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsGetPaymentForm, func(ctx context.Context, layerRequest *tg.PaymentsGetPaymentFormRequest) (any, error) { return r.onPaymentsGetPaymentForm(ctx, layerRequest) }) @@ -75,6 +83,36 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { registerRPC[*tg.PaymentsUpgradeStarGiftRequest](d, tlprofile.SemanticMethodPaymentsUpgradeStarGift, func(ctx context.Context, layerRequest *tg.PaymentsUpgradeStarGiftRequest) (any, error) { return r.onPaymentsUpgradeStarGift(ctx, layerRequest) }) + registerRPC[*tg.PaymentsUpdateStarGiftPriceRequest](d, tlprofile.SemanticMethodPaymentsUpdateStarGiftPrice, func(ctx context.Context, req *tg.PaymentsUpdateStarGiftPriceRequest) (any, error) { + return r.onPaymentsUpdateStarGiftPrice(ctx, req) + }) + registerRPC[*tg.PaymentsTransferStarGiftRequest](d, tlprofile.SemanticMethodPaymentsTransferStarGift, func(ctx context.Context, req *tg.PaymentsTransferStarGiftRequest) (any, error) { + return r.onPaymentsTransferStarGift(ctx, req) + }) + registerRPC[*tg.PaymentsGetStarGiftWithdrawalURLRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftWithdrawalURL, func(ctx context.Context, req *tg.PaymentsGetStarGiftWithdrawalURLRequest) (any, error) { + return r.onPaymentsGetStarGiftWithdrawalURL(ctx, req) + }) + registerRPC[*tg.PaymentsSendStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsSendStarGiftOffer, func(ctx context.Context, req *tg.PaymentsSendStarGiftOfferRequest) (any, error) { + return r.onPaymentsSendStarGiftOffer(ctx, req) + }) + registerRPC[*tg.PaymentsResolveStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsResolveStarGiftOffer, func(ctx context.Context, req *tg.PaymentsResolveStarGiftOfferRequest) (any, error) { + return r.onPaymentsResolveStarGiftOffer(ctx, req) + }) + registerRPC[*tg.PaymentsGetCraftStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetCraftStarGifts, func(ctx context.Context, req *tg.PaymentsGetCraftStarGiftsRequest) (any, error) { + return r.onPaymentsGetCraftStarGifts(ctx, req) + }) + registerRPC[*tg.PaymentsCraftStarGiftRequest](d, tlprofile.SemanticMethodPaymentsCraftStarGift, func(ctx context.Context, req *tg.PaymentsCraftStarGiftRequest) (any, error) { + return r.onPaymentsCraftStarGift(ctx, req) + }) + registerRPC[*tg.PaymentsGetStarGiftAuctionStateRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionState, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionStateRequest) (any, error) { + return r.onPaymentsGetStarGiftAuctionState(ctx, req) + }) + registerRPC[*tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionAcquiredGifts, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest) (any, error) { + return r.onPaymentsGetStarGiftAuctionAcquiredGifts(ctx, req) + }) + registerRPC[*tg.PaymentsToggleChatStarGiftNotificationsRequest](d, tlprofile.SemanticMethodPaymentsToggleChatStarGiftNotifications, func(ctx context.Context, req *tg.PaymentsToggleChatStarGiftNotificationsRequest) (any, error) { + return r.onPaymentsToggleChatStarGiftNotifications(ctx, req) + }) registerRPC[*tg.PaymentsGetStarGiftCollectionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftCollections, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftCollectionsRequest) (any, error) { return r.onPaymentsGetStarGiftCollections(ctx, layerRequest) }) @@ -108,36 +146,116 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { return &tg.PaymentsStarsRevenueAdsAccountURL{URL: "https://ads.telegram.org/"}, nil }) registerRPC[*tg.PaymentsGetStarsRevenueStatsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueStats, func(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (any, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req == nil { - return nil, peerIDInvalidErr() - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - return tdesktop.StarsRevenueStats(req.GetTon()), nil + return r.onPaymentsGetStarsRevenueStats(ctx, req) }) } -// onPaymentsGetStarsStatus 返回当前账号的 Stars 余额(首读时惰性授予起始余额)。 -// 响应必须是 payments.starsStatus(balance/chats/users 都是必填,空 vector 即可)—— -// 两端客户端无条件读取 balance(DrKLO StarsAmount 反序列化 / TDesktop vbalance())。 -func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) { - if req != nil && req.GetTon() { - // TON 余额未建模:返回 0 nanoton 的合法响应。 - return emptyStarsStatus(&tg.StarsTonAmount{}), nil - } - if r.deps.Stars == nil { - return emptyStarsStatus(&tg.StarsAmount{}), nil - } +// onPaymentsGetStarsRevenueStats exposes real channel Star Gift proceeds from +// the same peer-scoped ledger as getStarsStatus/getStarsTransactions. Personal +// and bot revenue remain the bounded compatibility response because their +// revenue bucket is distinct from the general Stars balance and is not modeled. +func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (*tg.PaymentsStarsRevenueStats, error) { userID, _, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } + if req == nil { + return nil, peerIDInvalidErr() + } + owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + ton := req.GetTon() + if owner.Type != domain.PeerTypeChannel { + return tdesktop.StarsRevenueStats(ton), nil + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil { + return nil, err + } + ledger, ok := r.deps.Gifts.(channelGiftLedgerReader) + if !ok { + return tdesktop.StarsRevenueStats(ton), nil + } + var balance int64 + if ton { + balance, err = ledger.ChannelTonBalance(ctx, owner.ID) + } else { + balance, err = ledger.ChannelStarsBalance(ctx, owner.ID) + } + if err != nil { + return nil, internalErr() + } + stats := tdesktop.StarsRevenueStats(ton) + var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance} + if ton { + amount = &tg.StarsTonAmount{Amount: balance} + } + // Channel ledgers currently only receive collectible conversion/marketplace + // proceeds and have no withdrawal/debit path, so balance equals lifetime + // revenue. Withdrawal stays disabled because no external payout exists. + stats.Status.CurrentBalance = amount + stats.Status.AvailableBalance = amount + stats.Status.OverallRevenue = amount + return stats, nil +} + +type channelGiftLedgerReader interface { + ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) + ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) + ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) + ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) +} + +// onPaymentsGetStarsStatus 返回请求 peer 的 Stars/本地 TON 余额。个人与频道账本 +// 严格隔离;频道读取要求 Star Gift 管理权限,不能把频道收益投影到执行 RPC 的管理员。 +// 响应必须是 payments.starsStatus(balance/chats/users 都是必填,空 vector 即可)—— +// 两端客户端无条件读取 balance(DrKLO StarsAmount 反序列化 / TDesktop vbalance())。 +func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) { + userID, owner, err := r.starGiftLedgerOwner(ctx, req) + if err != nil { + return nil, err + } + ton := req != nil && req.GetTon() + if owner.Type == domain.PeerTypeChannel { + ledger, ok := r.deps.Gifts.(channelGiftLedgerReader) + if !ok { + if ton { + return emptyStarsStatus(&tg.StarsTonAmount{}), nil + } + return emptyStarsStatus(&tg.StarsAmount{}), nil + } + var balance int64 + if ton { + balance, err = ledger.ChannelTonBalance(ctx, owner.ID) + } else { + balance, err = ledger.ChannelStarsBalance(ctx, owner.ID) + } + if err != nil { + return nil, internalErr() + } + var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance} + if ton { + amount = &tg.StarsTonAmount{Amount: balance} + } + out := emptyStarsStatus(amount) + out.Chats = r.tgChatsForChannelIDs(ctx, userID, []int64{owner.ID}) + return out, nil + } + if ton { + if r.deps.Gifts == nil { + return emptyStarsStatus(&tg.StarsTonAmount{}), nil + } + balance, err := r.deps.Gifts.TonBalance(ctx, userID) + if err != nil { + return nil, internalErr() + } + return emptyStarsStatus(&tg.StarsTonAmount{Amount: balance}), nil + } + if r.deps.Stars == nil { + return emptyStarsStatus(&tg.StarsAmount{}), nil + } bal, err := r.deps.Stars.GetBalance(ctx, userID) if err != nil { return nil, starsErr(err) @@ -148,24 +266,82 @@ func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsG // onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。 // 末页必须省略 next_offset(flag 不置),否则 DrKLO 会无限翻页。 func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) { - if req != nil && req.GetTon() { - return emptyStarsStatus(&tg.StarsTonAmount{}), nil - } - if r.deps.Stars == nil { - return emptyStarsStatus(&tg.StarsAmount{}), nil - } - userID, _, err := r.currentUserID(ctx) + userID, owner, err := r.starGiftTransactionLedgerOwner(ctx, req) if err != nil { - return nil, internalErr() + return nil, err } - offset := "" - limit := domain.MaxStarsTransactionsLimit + offset, limit := "", domain.MaxStarsTransactionsLimit if req != nil { offset = req.Offset if req.Limit > 0 { limit = req.Limit } } + ton := req != nil && req.GetTon() + if owner.Type == domain.PeerTypeChannel { + ledger, ok := r.deps.Gifts.(channelGiftLedgerReader) + if !ok { + if ton { + return emptyStarsStatus(&tg.StarsTonAmount{}), nil + } + return emptyStarsStatus(&tg.StarsAmount{}), nil + } + if ton { + page, err := ledger.ChannelTonTransactions(ctx, owner.ID, offset, limit) + if err != nil { + return nil, internalErr() + } + out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance}) + if txns := tgTonTransactions(page.Transactions); len(txns) > 0 { + out.SetHistory(txns) + } + if page.NextOffset != "" { + out.SetNextOffset(page.NextOffset) + } + r.enrichChannelTonLedgerStatus(ctx, userID, owner.ID, page.Transactions, out) + return out, nil + } + page, err := ledger.ChannelStarsTransactions(ctx, owner.ID, offset, limit) + if err != nil { + return nil, internalErr() + } + out := emptyStarsStatus(&tg.StarsAmount{Amount: page.Balance}) + if txns := tgStarsTransactions(page.Transactions); len(txns) > 0 { + out.SetHistory(txns) + } + if page.NextOffset != "" { + out.SetNextOffset(page.NextOffset) + } + r.enrichChannelStarsLedgerStatus(ctx, userID, owner.ID, page.Transactions, out) + return out, nil + } + if ton { + if r.deps.Gifts == nil { + return emptyStarsStatus(&tg.StarsTonAmount{}), nil + } + page, err := r.deps.Gifts.TonTransactions(ctx, userID, offset, limit) + if err != nil { + return nil, internalErr() + } + out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance}) + if txns := tgTonTransactions(page.Transactions); len(txns) > 0 { + out.SetHistory(txns) + } + if page.NextOffset != "" { + out.SetNextOffset(page.NextOffset) + } + ids := make([]int64, 0) + for _, txn := range page.Transactions { + if txn.Peer.Type == domain.PeerTypeUser { + ids = append(ids, txn.Peer.ID) + } + } + out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, uniqueInt64(ids))) + return out, nil + } + if r.deps.Stars == nil { + return emptyStarsStatus(&tg.StarsAmount{}), nil + } page, err := r.deps.Stars.ListTransactions(ctx, userID, offset, limit) if err != nil { return nil, starsErr(err) @@ -184,6 +360,71 @@ func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.Pay return out, nil } +func (r *Router) starGiftLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (int64, domain.Peer, error) { + if req == nil { + return 0, domain.Peer{}, peerIDInvalidErr() + } + return r.starGiftLedgerOwnerForPeer(ctx, req.Peer) +} + +func (r *Router) starGiftTransactionLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (int64, domain.Peer, error) { + if req == nil { + return 0, domain.Peer{}, peerIDInvalidErr() + } + return r.starGiftLedgerOwnerForPeer(ctx, req.Peer) +} + +func (r *Router) starGiftLedgerOwnerForPeer(ctx context.Context, input tg.InputPeerClass) (int64, domain.Peer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, domain.Peer{}, internalErr() + } + owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return 0, domain.Peer{}, err + } + if owner.Type == domain.PeerTypeUser { + if owner.ID != userID { + return 0, domain.Peer{}, peerIDInvalidErr() + } + return userID, owner, nil + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil { + return 0, domain.Peer{}, err + } + return userID, owner, nil +} + +func (r *Router) enrichChannelStarsLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.StarsTransaction, out *tg.PaymentsStarsStatus) { + userIDs := make([]int64, 0, len(txns)) + channelIDs := []int64{ownerChannelID} + for _, txn := range txns { + switch txn.Peer.Type { + case domain.PeerTypeUser: + userIDs = append(userIDs, txn.Peer.ID) + case domain.PeerTypeChannel: + channelIDs = append(channelIDs, txn.Peer.ID) + } + } + out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs))) + out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs)) +} + +func (r *Router) enrichChannelTonLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.TonTransaction, out *tg.PaymentsStarsStatus) { + userIDs := make([]int64, 0, len(txns)) + channelIDs := []int64{ownerChannelID} + for _, txn := range txns { + switch txn.Peer.Type { + case domain.PeerTypeUser: + userIDs = append(userIDs, txn.Peer.ID) + case domain.PeerTypeChannel: + channelIDs = append(channelIDs, txn.Peer.ID) + } + } + out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs))) + out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs)) +} + // emptyStarsStatus 构造一个合法的最小 payments.starsStatus(chats/users 非空 vector 但可空)。 func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus { return &tg.PaymentsStarsStatus{ @@ -214,6 +455,49 @@ func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction { item.Reaction = true case domain.StarsReasonGift: item.Gift = true + case domain.StarsReasonGiftUpgrade: + item.StargiftUpgrade = true + case domain.StarsReasonGiftResale: + item.StargiftResale = true + case domain.StarsReasonGiftPrepaid: + item.StargiftPrepaidUpgrade = true + case domain.StarsReasonGiftDrop: + item.StargiftDropOriginalDetails = true + case domain.StarsReasonGiftAuction: + item.StargiftAuctionBid = true + case domain.StarsReasonGiftOffer: + item.Offer = true + } + out = append(out, item) + } + return out +} + +func tgTonTransactions(in []domain.TonTransaction) []tg.StarsTransaction { + out := make([]tg.StarsTransaction, 0, len(in)) + for _, t := range in { + item := tg.StarsTransaction{ID: strconv.FormatInt(t.ID, 10), Amount: &tg.StarsTonAmount{Amount: t.Amount}, + Date: t.Date, Peer: tgStarsTransactionPeer(domain.StarsTransaction{Peer: t.Peer, Reason: t.Reason})} + if t.Amount > 0 { + item.Refund = true + } + if t.Title != "" { + item.SetTitle(t.Title) + } + if t.Description != "" { + item.SetDescription(t.Description) + } + switch t.Reason { + case domain.StarsReasonGiftResale: + item.StargiftResale = true + case domain.StarsReasonGiftOffer: + item.Offer = true + case domain.StarsReasonGiftAuction: + item.StargiftAuctionBid = true + case domain.StarsReasonGiftPrepaid: + item.StargiftPrepaidUpgrade = true + case domain.StarsReasonGiftDrop: + item.StargiftDropOriginalDetails = true } out = append(out, item) } diff --git a/internal/rpc/payments_star_gift_catalog_projection_test.go b/internal/rpc/payments_star_gift_catalog_projection_test.go new file mode 100644 index 00000000..eb241e93 --- /dev/null +++ b/internal/rpc/payments_star_gift_catalog_projection_test.go @@ -0,0 +1,98 @@ +package rpc + +import ( + "testing" + + "github.com/iamxvbaba/td/bin" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tlprofile" + + "telesrv/internal/domain" +) + +func TestStarGiftCatalogProjectionKeepsSaleDatesBehindSoldOutFlag(t *testing.T) { + base := domain.StarGift{ + ID: 8001, + RevisionID: 9001, + Stars: 100, + ConvertStars: 85, + Title: "Fresh Socks", + FirstSaleDate: 100, + LastSaleDate: 200, + Sticker: domain.Document{ + ID: 700, + AccessHash: 7, + DCID: 2, + MimeType: "application/x-tgsticker", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}, + }, + } + + tests := []struct { + name string + gift domain.StarGift + wantSoldOut bool + wantSaleDate bool + }{ + {name: "unlimited live gift with operational sale history", gift: base}, + {name: "limited live gift", gift: func() domain.StarGift { + gift := base + gift.Limited = true + gift.AvailabilityRemains = 9 + gift.AvailabilityTotal = 10 + return gift + }()}, + {name: "sold out gift", gift: func() domain.StarGift { + gift := base + gift.Limited = true + gift.SoldOut = true + gift.AvailabilityTotal = 10 + return gift + }(), wantSoldOut: true, wantSaleDate: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for _, profile := range []tlprofile.Profile{ + tlprofile.Profile225, + tlprofile.Profile226, + tlprofile.Profile227, + tlprofile.Profile228, + } { + response := &tg.PaymentsStarGifts{ + Hash: 1, + Gifts: []tg.StarGiftClass{tgStarGift(test.gift)}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } + wire := &bin.Buffer{} + if err := tlprofile.EncodeObject(profile, response, wire); err != nil { + t.Fatalf("encode Layer %d catalog: %v", profile, err) + } + decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("decode Layer %d catalog: %v", profile, err) + } + decoded, ok := decodedObject.(*tg.PaymentsStarGifts) + if !ok || len(decoded.Gifts) != 1 { + t.Fatalf("decode Layer %d catalog = %T %#v", profile, decodedObject, decodedObject) + } + gift, ok := decoded.Gifts[0].(*tg.StarGift) + if !ok { + t.Fatalf("decode Layer %d gift = %T", profile, decoded.Gifts[0]) + } + if gift.SoldOut != test.wantSoldOut { + t.Fatalf("Layer %d sold_out = %v, want %v", profile, gift.SoldOut, test.wantSoldOut) + } + first, firstSet := gift.GetFirstSaleDate() + last, lastSet := gift.GetLastSaleDate() + if firstSet != test.wantSaleDate || lastSet != test.wantSaleDate { + t.Fatalf("Layer %d sale date flags = (%v,%v), want %v", profile, firstSet, lastSet, test.wantSaleDate) + } + if test.wantSaleDate && (first != test.gift.FirstSaleDate || last != test.gift.LastSaleDate) { + t.Fatalf("Layer %d sale dates = (%d,%d), want (%d,%d)", profile, first, last, test.gift.FirstSaleDate, test.gift.LastSaleDate) + } + } + }) + } +} diff --git a/internal/rpc/payments_star_gift_lifecycle.go b/internal/rpc/payments_star_gift_lifecycle.go new file mode 100644 index 00000000..904eae74 --- /dev/null +++ b/internal/rpc/payments_star_gift_lifecycle.go @@ -0,0 +1,1019 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + "hash/fnv" + "strings" + + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + + "telesrv/internal/domain" +) + +func (r *Router) starGiftTransferPaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftTransfer) (tg.PaymentsPaymentFormClass, error) { + target, to, err := r.starGiftPaidTransferTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + return &tg.PaymentsPaymentFormStarGift{FormID: starGiftLifecycleFormID("transfer", userID, + target.ID, target.Owner.Type, target.Owner.ID, to.Type, to.ID, target.TransferStars, target.CanTransferAt), + Invoice: tg.Invoice{Currency: "XTR", Prices: []tg.LabeledPrice{{Label: "Collectible gift transfer", Amount: target.TransferStars}}}}, nil +} + +func (r *Router) sendStarGiftTransferForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftTransfer) (tg.PaymentsPaymentResultClass, error) { + target, to, err := r.starGiftPaidTransferTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + wantFormID := starGiftLifecycleFormID("transfer", userID, + target.ID, target.Owner.Type, target.Owner.ID, to.Type, to.ID, target.TransferStars, target.CanTransferAt) + if formID == 0 || formID != wantFormID { + return nil, starsFormAmountMismatchErr() + } + if r.deps.Stars != nil { + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + } + result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID, + Ref: domain.SavedStarGiftRef{Owner: target.Owner, MsgID: target.MsgID, SavedID: target.SavedID}, To: to, + ChargeStars: target.TransferStars, FormID: formID, CommandKey: fmt.Sprintf("paid-transfer:%d:%d", target.ID, formID), + Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + r.invalidateStarGiftOwner(target.Owner) + r.invalidateStarGiftOwner(to) + return &tg.PaymentsPaymentResult{Updates: r.starGiftTransferUpdates(ctx, userID, result, false)}, nil +} + +func (r *Router) starGiftPaidTransferTarget(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftTransfer) (domain.SavedStarGift, domain.Peer, error) { + if inv == nil || r.deps.Gifts == nil { + return domain.SavedStarGift{}, domain.Peer{}, starGiftInvalidErr() + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, inv.Stargift) + if err != nil || !ok { + return domain.SavedStarGift{}, domain.Peer{}, starGiftInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil { + return domain.SavedStarGift{}, domain.Peer{}, err + } + to, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.ToID) + if err != nil { + return domain.SavedStarGift{}, domain.Peer{}, err + } + saved, found, err := r.deps.Gifts.GetSaved(ctx, ref) + if err != nil { + return domain.SavedStarGift{}, domain.Peer{}, internalErr() + } + if !found || saved.UniqueGiftID == 0 || !saved.LifecycleStatus.Live() || saved.TransferStars <= 0 || saved.Owner == to { + return domain.SavedStarGift{}, domain.Peer{}, starGiftInvalidErr() + } + return saved, to, nil +} + +func (r *Router) starGiftResalePaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftResale) (tg.PaymentsPaymentFormClass, error) { + gift, to, amount, err := r.starGiftResaleTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + currency := string(amount.Currency) + return &tg.PaymentsPaymentFormStarGift{FormID: starGiftLifecycleFormID("resale", userID, + gift.ID, gift.Owner.Type, gift.Owner.ID, to.Type, to.ID, amount.Currency, amount.Amount, gift.ResellVersion), + Invoice: tg.Invoice{Currency: currency, Prices: []tg.LabeledPrice{{Label: "Collectible gift resale", Amount: amount.Amount}}}}, nil +} + +func (r *Router) sendStarGiftResaleForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftResale) (tg.PaymentsPaymentResultClass, error) { + gift, to, amount, err := r.starGiftResaleTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + wantFormID := starGiftLifecycleFormID("resale", userID, + gift.ID, gift.Owner.Type, gift.Owner.ID, to.Type, to.ID, amount.Currency, amount.Amount, gift.ResellVersion) + if formID == 0 || formID != wantFormID { + return nil, starsFormAmountMismatchErr() + } + if amount.Currency == domain.StarGiftCurrencyTON { + if _, err := r.deps.Gifts.TonBalance(ctx, userID); err != nil { + return nil, internalErr() + } + } else if r.deps.Stars != nil { + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + } + result, err := r.deps.Gifts.PurchaseResale(ctx, domain.StarGiftResalePurchaseRequest{BuyerUserID: userID, + Slug: gift.Slug, To: to, Amount: amount, FormID: formID, CommandKey: fmt.Sprintf("resale:%d:%d", gift.ID, formID), + Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + r.invalidateStarGiftOwner(gift.Owner) + r.invalidateStarGiftOwner(to) + return &tg.PaymentsPaymentResult{Updates: r.starGiftTransferUpdates(ctx, userID, result, amount.Currency == domain.StarGiftCurrencyTON)}, nil +} + +func (r *Router) starGiftResaleTarget(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftResale) (domain.UniqueStarGift, domain.Peer, domain.StarGiftAmount, error) { + if inv == nil || r.deps.Gifts == nil || strings.TrimSpace(inv.Slug) == "" { + return domain.UniqueStarGift{}, domain.Peer{}, domain.StarGiftAmount{}, starGiftInvalidErr() + } + gift, found, err := r.deps.Gifts.UniqueBySlug(ctx, inv.Slug) + if err != nil { + return domain.UniqueStarGift{}, domain.Peer{}, domain.StarGiftAmount{}, internalErr() + } + if !found || gift.ResellAmount == nil || gift.Burned || gift.OwnerAddress != "" { + return domain.UniqueStarGift{}, domain.Peer{}, domain.StarGiftAmount{}, starGiftInvalidErr() + } + to, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.ToID) + if err != nil { + return domain.UniqueStarGift{}, domain.Peer{}, domain.StarGiftAmount{}, err + } + wantCurrency := domain.StarGiftCurrencyStars + if inv.Ton { + wantCurrency = domain.StarGiftCurrencyTON + } + if gift.ResellAmount.Currency != wantCurrency || gift.Owner == to { + return domain.UniqueStarGift{}, domain.Peer{}, domain.StarGiftAmount{}, starGiftInvalidErr() + } + return gift, to, *gift.ResellAmount, nil +} + +func (r *Router) starGiftAuctionBidPaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftAuctionBid) (tg.PaymentsPaymentFormClass, error) { + state, peer, delta, err := r.starGiftAuctionBidTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + return &tg.PaymentsPaymentFormStars{FormID: starGiftLifecycleFormID("auction", userID, + state.Gift.ID, peer.Type, peer.ID, inv.BidAmount, state.Version), + BotID: domain.OfficialSystemUserID, Title: state.Gift.Title, Description: "Collectible gift auction bid", + Invoice: tg.Invoice{Currency: "XTR", Prices: []tg.LabeledPrice{{Label: "Auction bid", Amount: delta}}}, + Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()})}, nil +} + +func (r *Router) sendStarGiftAuctionBidForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftAuctionBid) (tg.PaymentsPaymentResultClass, error) { + state, peer, _, err := r.starGiftAuctionBidTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + wantFormID := starGiftLifecycleFormID("auction", userID, + state.Gift.ID, peer.Type, peer.ID, inv.BidAmount, state.Version) + if formID == 0 || formID != wantFormID { + return nil, starsFormAmountMismatchErr() + } + if r.deps.Stars != nil { + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + } + message := "" + if text, ok := inv.GetMessage(); ok { + message = clampGiftMessage(text.Text) + } + newState, balance, err := r.deps.Gifts.BidAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: userID, + GiftID: inv.GiftID, Peer: peer, BidAmount: inv.BidAmount, HideName: inv.HideName, Message: message, + UpdateBid: inv.UpdateBid, FormID: formID, Date: int(r.clock.Now().Unix())}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := emptyGiftUpdates(r.clock.Now().Unix()) + updates.Updates = append(updates.Updates, + &tg.UpdateStarGiftAuctionState{GiftID: inv.GiftID, State: tgStarGiftAuctionState(newState)}, + &tg.UpdateStarGiftAuctionUserState{GiftID: inv.GiftID, UserState: tgStarGiftAuctionUserState(newState.UserState)}) + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, balance.Balance) + return &tg.PaymentsPaymentResult{Updates: updates}, nil +} + +func (r *Router) starGiftAuctionBidTarget(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftAuctionBid) (domain.StarGiftAuction, domain.Peer, int64, error) { + if inv == nil || r.deps.Gifts == nil || inv.GiftID <= 0 || inv.BidAmount <= 0 { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + state, err := r.deps.Gifts.AuctionState(ctx, userID, inv.GiftID, "", int(r.clock.Now().Unix())) + if err != nil { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftLifecycleErr(err) + } + oldAmount := state.UserState.BidAmount + peer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + if inv.UpdateBid { + if oldAmount <= 0 || inv.HideName { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + if _, ok := inv.GetPeer(); ok { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + if _, ok := inv.GetMessage(); ok { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + peer = state.UserState.BidPeer + } else { + if oldAmount > 0 { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + if inputPeer, ok := inv.GetPeer(); ok { + peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer) + if err != nil { + return domain.StarGiftAuction{}, domain.Peer{}, 0, err + } + } + } + if peer.Type == domain.PeerTypeChannel { + if err := r.checkStarGiftOwnerPermission(ctx, userID, peer); err != nil { + return domain.StarGiftAuction{}, domain.Peer{}, 0, err + } + } + minimum := state.MinBidAmount + if oldAmount > 0 { + minimum = state.UserState.MinBidAmount + } + if inv.BidAmount < minimum || inv.BidAmount <= oldAmount { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + return state, peer, inv.BidAmount - oldAmount, nil +} + +func (r *Router) starGiftPrepaidUpgradePaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftPrepaidUpgrade) (tg.PaymentsPaymentFormClass, error) { + owner, target, price, err := r.starGiftPrepaidUpgradeTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + formID := starGiftLifecycleFormID("prepay-upgrade", userID, owner.Type, owner.ID, target.ID, inv.Hash, price) + return &tg.PaymentsPaymentFormStarGift{FormID: formID, + Invoice: tg.Invoice{Currency: "XTR", Prices: []tg.LabeledPrice{{Label: "Prepaid collectible gift upgrade", Amount: price}}}}, nil +} + +func (r *Router) sendStarGiftPrepaidUpgradeForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftPrepaidUpgrade) (tg.PaymentsPaymentResultClass, error) { + if inv == nil || r.deps.Gifts == nil || formID == 0 { + return nil, starGiftInvalidErr() + } + owner, target, price, targetErr := r.starGiftPrepaidUpgradeTarget(ctx, userID, inv) + commandKey := fmt.Sprintf("prepay-upgrade:%s:%d", strings.TrimSpace(inv.Hash), formID) + if targetErr == nil { + if formID != starGiftLifecycleFormID("prepay-upgrade", userID, owner.Type, owner.ID, target.ID, inv.Hash, price) { + return nil, starsFormAmountMismatchErr() + } + } else { + var err error + owner, err = r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer) + if err != nil { + return nil, err + } + price = 0 // accepted only by the store's exact replay path + } + if r.deps.Stars != nil { + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + } + result, err := r.deps.Gifts.PrepayUpgrade(ctx, domain.StarGiftPrepaidUpgradeRequest{PayerUserID: userID, + Owner: owner, Hash: strings.TrimSpace(inv.Hash), ChargeStars: price, FormID: formID, CommandKey: commandKey, + Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := r.starGiftSendUpdates(ctx, userID, result.Send) + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, result.Balance.Balance) + r.invalidateStarGiftOwner(owner) + return &tg.PaymentsPaymentResult{Updates: updates}, nil +} + +func (r *Router) starGiftPrepaidUpgradeTarget(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftPrepaidUpgrade) (domain.Peer, domain.SavedStarGift, int64, error) { + if inv == nil || r.deps.Gifts == nil || strings.TrimSpace(inv.Hash) == "" { + return domain.Peer{}, domain.SavedStarGift{}, 0, starGiftInvalidErr() + } + owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer) + if err != nil { + return domain.Peer{}, domain.SavedStarGift{}, 0, err + } + target, price, err := r.deps.Gifts.PrepaidUpgradeTarget(ctx, owner, strings.TrimSpace(inv.Hash)) + if err != nil { + return domain.Peer{}, domain.SavedStarGift{}, 0, starGiftLifecycleErr(err) + } + return owner, target, price, nil +} + +func (r *Router) starGiftDropDetailsPaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftDropOriginalDetails) (tg.PaymentsPaymentFormClass, error) { + target, err := r.starGiftDropDetailsTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + formID := starGiftLifecycleFormID("drop-details", userID, target.ID, target.UniqueGiftID, target.DropOriginalDetailsStars) + return &tg.PaymentsPaymentFormStarGift{FormID: formID, + Invoice: tg.Invoice{Currency: "XTR", Prices: []tg.LabeledPrice{{Label: "Remove collectible gift original details", Amount: target.DropOriginalDetailsStars}}}}, nil +} + +func (r *Router) sendStarGiftDropDetailsForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftDropOriginalDetails) (tg.PaymentsPaymentResultClass, error) { + if inv == nil || r.deps.Gifts == nil || formID == 0 { + return nil, starGiftInvalidErr() + } + ref, ok, refErr := r.starGiftRefFromInput(ctx, userID, inv.Stargift) + if refErr != nil || !ok { + return nil, starGiftInvalidErr() + } + target, targetErr := r.starGiftDropDetailsTarget(ctx, userID, inv) + charge := int64(0) + if targetErr == nil { + charge = target.DropOriginalDetailsStars + if formID != starGiftLifecycleFormID("drop-details", userID, target.ID, target.UniqueGiftID, charge) { + return nil, starsFormAmountMismatchErr() + } + } + if r.deps.Stars != nil { + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + } + result, err := r.deps.Gifts.DropOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{UserID: userID, + Ref: ref, ChargeStars: charge, FormID: formID, CommandKey: fmt.Sprintf("drop-details:%s:%s:%d", ref.Owner.Type, starGiftRefValue(ref), formID), + Date: int(r.clock.Now().Unix())}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := emptyGiftUpdates(r.clock.Now().Unix()) + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, result.Balance.Balance) + r.invalidateStarGiftOwner(ref.Owner) + return &tg.PaymentsPaymentResult{Updates: updates}, nil +} + +func (r *Router) starGiftDropDetailsTarget(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftDropOriginalDetails) (domain.SavedStarGift, error) { + if inv == nil || r.deps.Gifts == nil { + return domain.SavedStarGift{}, starGiftInvalidErr() + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, inv.Stargift) + if err != nil || !ok { + return domain.SavedStarGift{}, starGiftInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil { + return domain.SavedStarGift{}, err + } + target, found, err := r.deps.Gifts.GetSaved(ctx, ref) + if err != nil { + return domain.SavedStarGift{}, internalErr() + } + if !found || !target.LifecycleStatus.Live() || target.UniqueGiftID <= 0 || target.DropOriginalDetailsStars <= 0 { + return domain.SavedStarGift{}, starGiftInvalidErr() + } + return target, nil +} + +func starGiftLifecycleFormID(kind string, values ...any) int64 { + h := fnv.New64a() + _, _ = h.Write([]byte("telesrv:star-gift:" + kind + ":v1")) + for _, value := range values { + _, _ = fmt.Fprintf(h, ":%v", value) + } + id := int64(h.Sum64() & 0x7fffffffffffffff) + if id == 0 { + return 1 + } + return id +} + +func (r *Router) onPaymentsCheckCanSendGift(ctx context.Context, req *tg.PaymentsCheckCanSendGiftRequest) (tg.PaymentsCheckCanSendGiftResultClass, error) { + if req == nil || req.GiftID <= 0 || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + gift, found, err := r.deps.Gifts.GiftByID(ctx, req.GiftID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, starGiftInvalidErr() + } + now := int(r.clock.Now().Unix()) + switch { + case gift.SoldOut || gift.Limited && gift.AvailabilityRemains <= 0: + return &tg.PaymentsCheckCanSendGiftResultFail{Reason: tg.TextWithEntities{Text: "This gift is sold out."}}, nil + case gift.LockedUntilDate > now: + return &tg.PaymentsCheckCanSendGiftResultFail{Reason: tg.TextWithEntities{Text: "This gift is not available yet."}}, nil + case gift.Auction: + return &tg.PaymentsCheckCanSendGiftResultFail{Reason: tg.TextWithEntities{Text: "This gift is distributed through an auction."}}, nil + default: + return &tg.PaymentsCheckCanSendGiftResultOk{}, nil + } +} + +func (r *Router) onPaymentsGetUniqueStarGiftValueInfo(ctx context.Context, req *tg.PaymentsGetUniqueStarGiftValueInfoRequest) (*tg.PaymentsUniqueStarGiftValueInfo, error) { + if req == nil || strings.TrimSpace(req.Slug) == "" || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, req.Slug) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, starGiftInvalidErr() + } + info, err := r.deps.Gifts.ValueInfo(ctx, unique.ID) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + out := &tg.PaymentsUniqueStarGiftValueInfo{Currency: info.Currency, Value: info.Value, + InitialSaleDate: info.InitialSaleDate, InitialSaleStars: info.InitialSaleStars, + InitialSalePrice: info.InitialSalePrice} + if info.ValueIsAverage { + out.SetValueIsAverage(true) + } + if info.LastSaleDate > 0 { + out.SetLastSaleDate(info.LastSaleDate) + out.SetLastSalePrice(info.LastSalePrice) + } + if info.FloorPrice > 0 { + out.SetFloorPrice(info.FloorPrice) + } + if info.AveragePrice > 0 { + out.SetAveragePrice(info.AveragePrice) + } + out.SetListedCount(info.ListedCount) + return out, nil +} + +func (r *Router) onPaymentsGetResaleStarGifts(ctx context.Context, req *tg.PaymentsGetResaleStarGiftsRequest) (*tg.PaymentsResaleStarGifts, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + filter := domain.StarGiftResaleFilter{GiftID: req.GiftID, SortByPrice: req.SortByPrice, SortByNum: req.SortByNum, + ForCraft: req.ForCraft, StarsOnly: req.StarsOnly, Offset: req.Offset, Limit: req.Limit} + if filter.Limit <= 0 { + filter.Limit = domain.MaxSavedStarGiftsLimit + } + if attributes, ok := req.GetAttributes(); ok { + for _, attribute := range attributes { + switch value := attribute.(type) { + case *tg.StarGiftAttributeIDModel: + if value != nil && value.DocumentID > 0 { + filter.ModelIDs = append(filter.ModelIDs, value.DocumentID) + } + case *tg.StarGiftAttributeIDPattern: + if value != nil && value.DocumentID > 0 { + filter.PatternIDs = append(filter.PatternIDs, value.DocumentID) + } + case *tg.StarGiftAttributeIDBackdrop: + if value != nil && value.BackdropID > 0 { + filter.BackdropIDs = append(filter.BackdropIDs, int64(value.BackdropID)) + } + default: + return nil, starGiftInvalidErr() + } + } + } + page, err := r.deps.Gifts.ListResale(ctx, filter) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + out := &tg.PaymentsResaleStarGifts{Count: page.Count, Gifts: make([]tg.StarGiftClass, 0, len(page.Gifts)), + Users: []tg.UserClass{}, Chats: []tg.ChatClass{}} + userIDs, channelIDs := make([]int64, 0), make([]int64, 0) + for _, gift := range page.Gifts { + out.Gifts = append(out.Gifts, tgUniqueStarGift(gift)) + if gift.Owner.Type == domain.PeerTypeUser { + userIDs = append(userIDs, gift.Owner.ID) + } else if gift.Owner.Type == domain.PeerTypeChannel { + channelIDs = append(channelIDs, gift.Owner.ID) + } + } + if page.NextOffset != "" { + out.SetNextOffset(page.NextOffset) + } + viewerID, _, _ := r.currentUserID(ctx) + out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs))) + out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs)) + if attributesHash, requested := req.GetAttributesHash(); requested { + preview, found, previewErr := r.deps.Gifts.CollectiblePreview(ctx, req.GiftID) + if previewErr != nil { + return nil, internalErr() + } + if found { + hash := int64(preview.Revision) + out.SetAttributesHash(hash) + if attributesHash != hash { + attributes := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops)) + for _, attribute := range preview.Models { + attributes = append(attributes, tgStarGiftAttribute(attribute)) + } + for _, attribute := range preview.Patterns { + attributes = append(attributes, tgStarGiftAttribute(attribute)) + } + for _, attribute := range preview.Backdrops { + attributes = append(attributes, tgStarGiftAttribute(attribute)) + } + out.SetAttributes(attributes) + } + } + } + return out, nil +} + +func (r *Router) onPaymentsUpdateStarGiftPrice(ctx context.Context, req *tg.PaymentsUpdateStarGiftPriceRequest) (tg.UpdatesClass, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, req.Stargift) + if err != nil || !ok { + return nil, starGiftInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil { + return nil, err + } + var amount *domain.StarGiftAmount + switch value := req.ResellAmount.(type) { + case *tg.StarsAmount: + if value == nil || value.Amount < 0 || value.Nanos != 0 { + return nil, starGiftInvalidErr() + } + if value.Amount > 0 { + amount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: value.Amount} + } + case *tg.StarsTonAmount: + if value == nil || value.Amount < 0 { + return nil, starGiftInvalidErr() + } + if value.Amount > 0 { + amount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: value.Amount} + } + default: + return nil, starGiftInvalidErr() + } + if _, err := r.deps.Gifts.SetListing(ctx, domain.StarGiftListingRequest{ActorUserID: userID, Ref: ref, + Amount: amount, Date: int(r.clock.Now().Unix())}); err != nil { + return nil, starGiftLifecycleErr(err) + } + r.invalidateStarGiftOwner(ref.Owner) + return emptyGiftUpdates(r.clock.Now().Unix()), nil +} + +func (r *Router) onPaymentsTransferStarGift(ctx context.Context, req *tg.PaymentsTransferStarGiftRequest) (tg.UpdatesClass, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, req.Stargift) + if err != nil || !ok { + return nil, starGiftInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil { + return nil, err + } + to, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ToID) + if err != nil { + return nil, err + } + now := int(r.clock.Now().Unix()) + result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID, Ref: ref, To: to, + CommandKey: fmt.Sprintf("free:%s:%d:%s:%s:%d", ref.Owner.Type, ref.Owner.ID, starGiftRefValue(ref), to.Type, to.ID), + Date: now, OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + r.invalidateStarGiftOwner(ref.Owner) + r.invalidateStarGiftOwner(to) + return r.starGiftTransferUpdates(ctx, userID, result, false), nil +} + +func (r *Router) onPaymentsGetStarGiftWithdrawalURL(ctx context.Context, req *tg.PaymentsGetStarGiftWithdrawalURLRequest) (*tg.PaymentsStarGiftWithdrawalURL, error) { + if req == nil || r.deps.Gifts == nil || r.deps.Account == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(req.Password)); err != nil { + return nil, passwordErr(err) + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, req.Stargift) + if err != nil || !ok || ref.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return nil, starGiftInvalidErr() + } + withdrawal, err := r.deps.Gifts.Withdraw(ctx, domain.StarGiftWithdrawalRequest{UserID: userID, Ref: ref, Date: int(r.clock.Now().Unix())}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + return &tg.PaymentsStarGiftWithdrawalURL{URL: withdrawal.URL}, nil +} + +func (r *Router) onPaymentsSendStarGiftOffer(ctx context.Context, req *tg.PaymentsSendStarGiftOfferRequest) (tg.UpdatesClass, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + price, ok := domainStarGiftAmount(req.Price) + if !ok { + return nil, starGiftInvalidErr() + } + now := int(r.clock.Now().Unix()) + result, err := r.deps.Gifts.SendOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: userID, Owner: owner, + Slug: req.Slug, Price: price, Duration: req.Duration, RandomID: req.RandomID, Date: now, + OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := r.starGiftSendUpdates(ctx, userID, result.Send) + appendStarGiftBalanceUpdate(updates, price.Currency, result.Balance.Balance) + return updates, nil +} + +func (r *Router) onPaymentsResolveStarGiftOffer(ctx context.Context, req *tg.PaymentsResolveStarGiftOfferRequest) (tg.UpdatesClass, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + now := int(r.clock.Now().Unix()) + result, err := r.deps.Gifts.ResolveOffer(ctx, domain.StarGiftResolveOfferRequest{OwnerUserID: userID, + OfferMsgID: req.OfferMsgID, Decline: req.Decline, Date: now, OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), + OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := r.starGiftSendUpdates(ctx, userID, result.Send) + if !req.Decline { + if result.Offer.Price.Currency == domain.StarGiftCurrencyTON { + balance, _ := r.deps.Gifts.TonBalance(ctx, userID) + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyTON, balance) + } else if r.deps.Stars != nil { + balance, balanceErr := r.deps.Stars.GetBalance(ctx, userID) + if balanceErr == nil { + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, balance.Balance) + } + } + } + return updates, nil +} + +func (r *Router) onPaymentsGetCraftStarGifts(ctx context.Context, req *tg.PaymentsGetCraftStarGiftsRequest) (*tg.PaymentsSavedStarGifts, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + page, err := r.deps.Gifts.ListCraft(ctx, userID, req.GiftID, req.Offset, req.Limit) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + return r.tgSavedStarGiftsResponse(ctx, userID, page.Gifts, page.Count, page.NextOffset) +} + +func (r *Router) onPaymentsCraftStarGift(ctx context.Context, req *tg.PaymentsCraftStarGiftRequest) (tg.UpdatesClass, error) { + if req == nil || r.deps.Gifts == nil || len(req.Stargift) < 1 || len(req.Stargift) > 4 { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + refs := make([]domain.SavedStarGiftRef, 0, len(req.Stargift)) + commandParts := make([]string, 0, len(req.Stargift)) + seenSavedIDs := make(map[int64]struct{}, len(req.Stargift)) + for _, input := range req.Stargift { + ref, ok, err := r.starGiftRefFromInput(ctx, userID, input) + if err != nil || !ok || ref.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return nil, starGiftInvalidErr() + } + saved, found, err := r.deps.Gifts.GetSaved(ctx, ref) + if err != nil { + return nil, internalErr() + } + if !found || saved.ID <= 0 || saved.Owner != ref.Owner { + return nil, starGiftInvalidErr() + } + if _, duplicate := seenSavedIDs[saved.ID]; duplicate { + return nil, starGiftInvalidErr() + } + seenSavedIDs[saved.ID] = struct{}{} + refs = append(refs, ref) + // Official wire identities (user msg id, channel saved id or collectible + // slug) identify one durable aggregate and therefore one idempotency key. + commandParts = append(commandParts, fmt.Sprint(saved.ID)) + } + result, err := r.deps.Gifts.Craft(ctx, domain.StarGiftCraftRequest{UserID: userID, Refs: refs, + CommandKey: "rpc:" + strings.Join(commandParts, ","), Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + r.invalidateRPCProjectionForUser(userID) + updates := emptyGiftUpdates(r.clock.Now().Unix()) + if result.Success { + updates = r.starGiftSendUpdates(ctx, userID, result.Send) + } + sourceUpdates := make([]tg.UpdateClass, 0, len(result.SourceEdits)) + for _, edit := range result.SourceEdits { + if edit.UserID != userID { + continue + } + if update := tgOtherUpdateFromEvent(edit.Event); update != nil { + sourceUpdates = append(sourceUpdates, update) + updates.Users = append(updates.Users, r.usersForMessageUpdate(ctx, userID, edit.Message)...) + updates.Chats = append(updates.Chats, r.chatsForMessageUpdate(ctx, userID, edit.Message)...) + if edit.Event.Date > updates.Date { + updates.Date = edit.Event.Date + } + } + } + // Craft source edits reserve pts before the crafted output message, so keep + // them first in the immediate response as well. Other sessions receive the + // same durable edit events through outbox/difference. + updates.Updates = append(sourceUpdates, updates.Updates...) + if !result.Success { + updates.Updates = append(updates.Updates, &tg.UpdateStarGiftCraftFail{}) + } + return updates, nil +} + +func (r *Router) onPaymentsGetStarGiftAuctionState(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionStateRequest) (*tg.PaymentsStarGiftAuctionState, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + var giftID int64 + var slug string + switch value := req.Auction.(type) { + case *tg.InputStarGiftAuction: + if value != nil { + giftID = value.GiftID + } + case *tg.InputStarGiftAuctionSlug: + if value != nil { + slug = value.Slug + } + default: + return nil, starGiftInvalidErr() + } + state, err := r.deps.Gifts.AuctionState(ctx, userID, giftID, slug, int(r.clock.Now().Unix())) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + stateClass := tgStarGiftAuctionState(state) + if !state.Finished && req.Version == state.Version { + stateClass = &tg.StarGiftAuctionStateNotModified{} + } + return &tg.PaymentsStarGiftAuctionState{Gift: tgStarGift(state.Gift), State: stateClass, + UserState: tgStarGiftAuctionUserState(state.UserState), Timeout: 30, Users: r.auctionUsers(ctx, userID, state), Chats: []tg.ChatClass{}}, nil +} + +func (r *Router) onPaymentsGetStarGiftActiveAuctions(ctx context.Context, req *tg.PaymentsGetStarGiftActiveAuctionsRequest) (tg.PaymentsStarGiftActiveAuctionsClass, error) { + if req == nil { + return nil, starGiftInvalidErr() + } + if r.deps.Gifts == nil { + return &tg.PaymentsStarGiftActiveAuctions{Auctions: []tg.StarGiftActiveAuctionState{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + states, err := r.deps.Gifts.ActiveAuctions(ctx, userID, int(r.clock.Now().Unix())) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + out := &tg.PaymentsStarGiftActiveAuctions{Auctions: make([]tg.StarGiftActiveAuctionState, 0, len(states)), Users: []tg.UserClass{}, Chats: []tg.ChatClass{}} + userIDs := make([]int64, 0) + for _, state := range states { + out.Auctions = append(out.Auctions, tg.StarGiftActiveAuctionState{Gift: tgStarGift(state.Gift), + State: tgStarGiftAuctionState(state), UserState: tgStarGiftAuctionUserState(state.UserState)}) + userIDs = append(userIDs, state.TopBidders...) + } + out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, uniqueInt64(userIDs))) + return out, nil +} + +func (r *Router) onPaymentsGetStarGiftAuctionAcquiredGifts(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest) (*tg.PaymentsStarGiftAuctionAcquiredGifts, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + items, err := r.deps.Gifts.AuctionAcquired(ctx, userID, req.GiftID) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + out := &tg.PaymentsStarGiftAuctionAcquiredGifts{Gifts: make([]tg.StarGiftAuctionAcquiredGift, 0, len(items)), + Users: []tg.UserClass{}, Chats: []tg.ChatClass{}} + userIDs, channelIDs := make([]int64, 0), make([]int64, 0) + for _, item := range items { + gift := tg.StarGiftAuctionAcquiredGift{NameHidden: item.NameHidden, Peer: tgPeer(item.Peer), Date: item.Date, + BidAmount: item.BidAmount, Round: item.Round, Pos: item.Pos} + if item.Message != "" { + gift.SetMessage(tg.TextWithEntities{Text: item.Message}) + } + if item.GiftNum > 0 { + gift.SetGiftNum(item.GiftNum) + } + out.Gifts = append(out.Gifts, gift) + if item.Peer.Type == domain.PeerTypeUser { + userIDs = append(userIDs, item.Peer.ID) + } else { + channelIDs = append(channelIDs, item.Peer.ID) + } + } + out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, uniqueInt64(userIDs))) + out.Chats = r.tgChatsForChannelIDs(ctx, userID, uniqueInt64(channelIDs)) + return out, nil +} + +func (r *Router) onPaymentsToggleChatStarGiftNotifications(ctx context.Context, req *tg.PaymentsToggleChatStarGiftNotificationsRequest) (bool, error) { + if req == nil || r.deps.Gifts == nil || r.deps.Channels == nil { + return false, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil || peer.Type != domain.PeerTypeChannel { + return false, peerIDInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, peer); err != nil { + return false, err + } + if err := r.deps.Gifts.SetNotifications(ctx, userID, peer.ID, req.Enabled); err != nil { + return false, starGiftLifecycleErr(err) + } + return true, nil +} + +func tgStarGiftAuctionState(state domain.StarGiftAuction) tg.StarGiftAuctionStateClass { + if state.Finished { + out := &tg.StarGiftAuctionStateFinished{StartDate: state.StartDate, EndDate: state.EndDate, AveragePrice: state.AveragePrice} + if state.ListedCount > 0 { + out.SetListedCount(state.ListedCount) + } + return out + } + levels := make([]tg.AuctionBidLevel, 0, len(state.BidLevels)) + for _, level := range state.BidLevels { + levels = append(levels, tg.AuctionBidLevel{Pos: level.Pos, Amount: level.Amount, Date: level.Date}) + } + return &tg.StarGiftAuctionState{Version: state.Version, StartDate: state.StartDate, EndDate: state.EndDate, + MinBidAmount: state.MinBidAmount, BidLevels: levels, TopBidders: state.TopBidders, + NextRoundAt: state.NextRoundAt, LastGiftNum: state.LastGiftNum, GiftsLeft: state.GiftsLeft, + CurrentRound: state.CurrentRound, TotalRounds: state.TotalRounds, + Rounds: []tg.StarGiftAuctionRoundClass{&tg.StarGiftAuctionRound{Num: 1, Duration: state.RoundDuration}}} +} + +func tgStarGiftAuctionUserState(state domain.StarGiftAuctionUserState) tg.StarGiftAuctionUserState { + out := tg.StarGiftAuctionUserState{AcquiredCount: state.AcquiredCount} + if state.Returned { + out.SetReturned(true) + } + if state.BidAmount > 0 { + out.SetBidAmount(state.BidAmount) + out.SetBidDate(state.BidDate) + out.SetMinBidAmount(state.MinBidAmount) + out.SetBidPeer(tgPeer(state.BidPeer)) + } + return out +} + +func (r *Router) auctionUsers(ctx context.Context, viewerID int64, state domain.StarGiftAuction) []tg.UserClass { + ids := append([]int64(nil), state.TopBidders...) + if state.UserState.BidPeer.Type == domain.PeerTypeUser { + ids = append(ids, state.UserState.BidPeer.ID) + } + return tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(ids))) +} + +func (r *Router) starGiftSendUpdates(ctx context.Context, viewerID int64, send domain.SendPrivateTextResult) *tg.Updates { + message, event := send.SenderMessage, send.SenderEvent + if send.RecipientMessage.OwnerUserID == viewerID { + message, event = send.RecipientMessage, send.RecipientEvent + } else if send.SenderMessage.OwnerUserID != viewerID { + return emptyGiftUpdates(r.clock.Now().Unix()) + } + if message.ID <= 0 { + return emptyGiftUpdates(r.clock.Now().Unix()) + } + users := r.usersForMessageUpdate(ctx, viewerID, message) + chats := r.chatsForMessageUpdate(ctx, viewerID, message) + return tgPrivateMessageUpdates(event, message, 0, false, users, chats) +} + +func (r *Router) starGiftTransferUpdates(ctx context.Context, viewerID int64, result domain.StarGiftTransferResult, ton bool) *tg.Updates { + updates := r.starGiftSendUpdates(ctx, viewerID, result.Send) + currency := domain.StarGiftCurrencyStars + if ton { + currency = domain.StarGiftCurrencyTON + } + appendStarGiftBalanceUpdate(updates, currency, result.Balance.Balance) + return updates +} + +func appendStarGiftBalanceUpdate(updates *tg.Updates, currency domain.StarGiftCurrency, balance int64) { + if updates == nil { + return + } + var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance} + if currency == domain.StarGiftCurrencyTON { + amount = &tg.StarsTonAmount{Amount: balance} + } + updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: amount}) +} + +func emptyGiftUpdates(date int64) *tg.Updates { + return &tg.Updates{Updates: []tg.UpdateClass{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}, Date: int(date)} +} + +func (r *Router) checkStarGiftOwnerPermission(ctx context.Context, userID int64, owner domain.Peer) error { + if owner == (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return nil + } + if owner.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return peerIDInvalidErr() + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, owner.ID) + if err != nil { + return channelInvalidErr(err) + } + if view.Self.Role == domain.ChannelRoleCreator || view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.PostMessages { + return nil + } + return tgerr.New(400, "CHAT_ADMIN_REQUIRED") +} + +func (r *Router) invalidateStarGiftOwner(owner domain.Peer) { + if owner.Type == domain.PeerTypeUser { + r.invalidateRPCProjectionForUser(owner.ID) + } else if owner.Type == domain.PeerTypeChannel { + r.invalidateRPCProjectionForChannel(owner.ID) + } +} + +func starGiftRefValue(ref domain.SavedStarGiftRef) string { + if ref.Slug != "" { + return "slug:" + strings.ToLower(strings.TrimSpace(ref.Slug)) + } + if ref.Owner.Type == domain.PeerTypeChannel { + return fmt.Sprintf("saved:%d", ref.SavedID) + } + return fmt.Sprintf("msg:%d", ref.MsgID) +} + +func uniqueInt64(values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + if value <= 0 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func starGiftLifecycleErr(err error) error { + switch { + case errors.Is(err, domain.ErrStarGiftFormExpired): + return formExpiredErr() + case errors.Is(err, domain.ErrStarGiftFormPurposeInvalid): + return purposeInvalidErr() + case errors.Is(err, domain.ErrStarGiftFormAmountMismatch): + return starsFormAmountMismatchErr() + case errors.Is(err, domain.ErrStarsInsufficient): + return tgerr.New(400, "BALANCE_TOO_LOW") + case errors.Is(err, domain.ErrPremiumRequired): + return tgerr.New(400, "PREMIUM_ACCOUNT_REQUIRED") + case errors.Is(err, domain.ErrStarGiftOfferExpired): + return tgerr.New(400, "STARGIFT_OFFER_EXPIRED") + case errors.Is(err, domain.ErrStarGiftOwnerInvalid): + return tgerr.New(400, "STARGIFT_OWNER_INVALID") + case errors.Is(err, domain.ErrStarGiftWithdrawalUnavailable): + return tgerr.New(400, "STARGIFT_WITHDRAWAL_UNAVAILABLE") + case errors.Is(err, domain.ErrStarGiftNotFound), errors.Is(err, domain.ErrStarGiftResaleUnavailable), + errors.Is(err, domain.ErrStarGiftTransferUnavailable), errors.Is(err, domain.ErrStarGiftOfferInvalid), + errors.Is(err, domain.ErrStarGiftCraftUnavailable), errors.Is(err, domain.ErrStarGiftAuctionUnavailable), + errors.Is(err, domain.ErrStarGiftUnavailable), errors.Is(err, domain.ErrStarGiftInvalid), + errors.Is(err, domain.ErrStarGiftCollectibleUnavailable): + return starGiftInvalidErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/payments_star_gift_unique.go b/internal/rpc/payments_star_gift_unique.go index b9b54794..6f9cd424 100644 --- a/internal/rpc/payments_star_gift_unique.go +++ b/internal/rpc/payments_star_gift_unique.go @@ -26,18 +26,37 @@ func (r *Router) starGiftUpgradePaymentForm(ctx context.Context, userID int64, i } func (r *Router) sendStarGiftUpgradeForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentResultClass, error) { - saved, preview, err := r.starGiftUpgradeTarget(ctx, userID, inv.Stargift) + saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, inv.Stargift) if err != nil { return nil, err } - wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails) - if formID == 0 || formID != wantFormID { - return nil, starsFormAmountMismatchErr() + commandKey := fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails) + receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey) + if err != nil { + return nil, internalErr() + } + chargeStars := int64(0) + if replay { + if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != formID || receipt.RequirePrepaid || + receipt.KeepOriginalDetails != inv.KeepOriginalDetails || receipt.ChargeStars <= 0 { + return nil, starGiftInvalidErr() + } + chargeStars = receipt.ChargeStars + } else { + preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved) + if err != nil { + return nil, err + } + wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails) + if formID == 0 || formID != wantFormID { + return nil, starsFormAmountMismatchErr() + } + chargeStars = preview.UpgradeStars } result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{ - UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID}, - KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: preview.UpgradeStars, - FormID: formID, CommandKey: fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails), + UserID: userID, Ref: starGiftUpgradeSavedRef(saved), + KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: chargeStars, + FormID: formID, CommandKey: commandKey, Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx), }) @@ -57,17 +76,32 @@ func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.Payments if err != nil { return nil, internalErr() } - saved, _, err := r.starGiftUpgradeTarget(ctx, userID, req.Stargift) + saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, req.Stargift) if err != nil { return nil, err } - if saved.PrepaidUpgradeStars <= 0 { - return nil, starGiftInvalidErr() + commandKey := fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails) + receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey) + if err != nil { + return nil, internalErr() + } + if replay { + if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != 0 || !receipt.RequirePrepaid || + receipt.KeepOriginalDetails != req.KeepOriginalDetails || receipt.ChargeStars != 0 { + return nil, starGiftInvalidErr() + } + } else { + if _, err := r.starGiftUpgradePreviewForSaved(ctx, saved); err != nil { + return nil, err + } + if saved.PrepaidUpgradeStars <= 0 { + return nil, starGiftInvalidErr() + } } result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{ - UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID}, + UserID: userID, Ref: starGiftUpgradeSavedRef(saved), KeepOriginalDetails: req.KeepOriginalDetails, RequirePrepaid: true, - CommandKey: fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails), + CommandKey: commandKey, Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx), }) @@ -79,33 +113,60 @@ func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.Payments } func (r *Router) starGiftUpgradeTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, domain.StarGiftUpgradePreview, error) { - if r.deps.Gifts == nil { - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, notImplementedErr() - } - ref, ok, err := r.starGiftRefFromInput(ctx, userID, input) + saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, input) if err != nil { return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, err } - if !ok || ref.Owner.Type != domain.PeerTypeUser || ref.Owner.ID != userID { - // Channel gift upgrades require a channel pts aggregate and are not silently - // routed through the private-message transaction. - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr() + preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved) + return saved, preview, err +} + +func (r *Router) starGiftUpgradeSavedTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, error) { + if r.deps.Gifts == nil { + return domain.SavedStarGift{}, notImplementedErr() + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, input) + if err != nil { + return domain.SavedStarGift{}, err + } + if !ok { + return domain.SavedStarGift{}, starGiftInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil { + return domain.SavedStarGift{}, err } saved, found, err := r.deps.Gifts.GetSaved(ctx, ref) if err != nil { - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr() + return domain.SavedStarGift{}, internalErr() } - if !found || saved.Converted || saved.UniqueGiftID != 0 { - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr() + if !found { + return domain.SavedStarGift{}, starGiftInvalidErr() + } + return saved, nil +} + +func (r *Router) starGiftUpgradePreviewForSaved(ctx context.Context, saved domain.SavedStarGift) (domain.StarGiftUpgradePreview, error) { + if saved.Converted || saved.UniqueGiftID != 0 { + return domain.StarGiftUpgradePreview{}, starGiftInvalidErr() } preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, saved.GiftID) if err != nil { - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr() + return domain.StarGiftUpgradePreview{}, internalErr() } if !found || preview.UpgradeStars <= 0 || preview.Issued >= preview.SupplyTotal { - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr() + return domain.StarGiftUpgradePreview{}, starGiftInvalidErr() } - return saved, preview, nil + return preview, nil +} + +func starGiftUpgradeSavedRef(saved domain.SavedStarGift) domain.SavedStarGiftRef { + ref := domain.SavedStarGiftRef{Owner: saved.Owner} + if saved.Owner.Type == domain.PeerTypeChannel { + ref.SavedID = saved.SavedID + } else { + ref.MsgID = saved.MsgID + } + return ref } func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64, result domain.StarGiftUpgradeResult, includeBalance bool) *tg.Updates { @@ -116,6 +177,17 @@ func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64 updates := tgPrivateMessageUpdates(event, message, 0, false, r.usersForMessageUpdate(ctx, ownerUserID, message), r.chatsForMessageUpdate(ctx, ownerUserID, message)) + for _, edit := range result.SourceEdits { + if edit.UserID != ownerUserID { + continue + } + if update := tgOtherUpdateFromEvent(edit.Event); update != nil { + updates.Updates = append(updates.Updates, update) + if edit.Event.Date > updates.Date { + updates.Date = edit.Event.Date + } + } + } if includeBalance { updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.Balance.Balance}}) } @@ -175,6 +247,20 @@ func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID }, nil } +func (r *Router) onPaymentsGetStarGiftUpgradeAttributes(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradeAttributes, error) { + if giftID <= 0 || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, giftID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, starGiftInvalidErr() + } + return &tg.PaymentsStarGiftUpgradeAttributes{Attributes: tgAllStarGiftAttributes(preview)}, nil +} + func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (*tg.PaymentsUniqueStarGift, error) { if r.deps.Gifts == nil || strings.TrimSpace(slug) == "" { return nil, starGiftInvalidErr() @@ -211,6 +297,9 @@ func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) ( func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass { out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops)) for _, attribute := range preview.Models { + if attribute.Crafted { + continue + } out = append(out, tgStarGiftAttribute(attribute)) } for _, attribute := range preview.Patterns { @@ -222,15 +311,25 @@ func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.Sta return out } +func tgAllStarGiftAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass { + out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops)) + for _, attributes := range [][]domain.StarGiftCollectibleAttribute{preview.Models, preview.Patterns, preview.Backdrops} { + for _, attribute := range attributes { + out = append(out, tgStarGiftAttribute(attribute)) + } + } + return out +} + func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeClass { - rarity := &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille} + rarity := tgStarGiftAttributeRarity(attribute) switch attribute.Kind { case domain.StarGiftCollectibleModel: document := tg.DocumentClass(&tg.DocumentEmpty{}) if attribute.Document != nil { document = tgDocument(*attribute.Document) } - return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity} + return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity, Crafted: attribute.Crafted} case domain.StarGiftCollectiblePattern: document := tg.DocumentClass(&tg.DocumentEmpty{}) if attribute.Document != nil { @@ -248,6 +347,21 @@ func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarG } } +func tgStarGiftAttributeRarity(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeRarityClass { + switch attribute.RarityKind { + case domain.StarGiftRarityUncommon: + return &tg.StarGiftAttributeRarityUncommon{} + case domain.StarGiftRarityRare: + return &tg.StarGiftAttributeRarityRare{} + case domain.StarGiftRarityEpic: + return &tg.StarGiftAttributeRarityEpic{} + case domain.StarGiftRarityLegendary: + return &tg.StarGiftAttributeRarityLegendary{} + default: + return &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille} + } +} + func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique { attributes := []tg.StarGiftAttributeClass{ tgStarGiftAttribute(unique.Model), @@ -268,11 +382,73 @@ func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique { attributes = append(attributes, original) } out := &tg.StarGiftUnique{ + RequirePremium: unique.RequirePremium, ResaleTonOnly: unique.ResaleTonOnly, + ThemeAvailable: unique.ThemeAvailable, Burned: unique.Burned, Crafted: unique.Crafted, ID: unique.ID, GiftID: unique.GiftID, Title: unique.Title, Slug: unique.Slug, Num: unique.Num, Attributes: attributes, AvailabilityIssued: unique.AvailabilityIssued, AvailabilityTotal: unique.AvailabilityTotal, } - if owner := tgPeer(unique.Owner); owner != nil { + if unique.OwnerAddress != "" { + out.SetOwnerAddress(unique.OwnerAddress) + } else if owner := tgPeer(unique.Owner); owner != nil { out.SetOwnerID(owner) + } else if unique.OwnerName != "" { + out.SetOwnerName(unique.OwnerName) + } + if unique.GiftAddress != "" { + out.SetGiftAddress(unique.GiftAddress) + } + if unique.ResellAmount != nil { + out.SetResellAmount([]tg.StarsAmountClass{tgStarGiftAmount(*unique.ResellAmount)}) + } + if peer := tgPeer(unique.ReleasedBy); peer != nil { + out.SetReleasedBy(peer) + } + if unique.ValueAmount > 0 { + out.SetValueAmount(unique.ValueAmount) + } + if unique.ValueCurrency != "" { + out.SetValueCurrency(unique.ValueCurrency) + } + if unique.ValueUSD > 0 { + out.SetValueUsdAmount(unique.ValueUSD) + } + if peer := tgPeer(unique.ThemePeer); peer != nil { + out.SetThemePeer(peer) + } + if peer := tgPeer(unique.Host); peer != nil { + out.SetHostID(peer) + } + if unique.OfferMinStars > 0 && unique.Owner.Type == domain.PeerTypeUser { + out.SetOfferMinStars(unique.OfferMinStars) + } + if unique.CraftChancePermille > 0 { + out.SetCraftChancePermille(unique.CraftChancePermille) } return out } + +func tgStarGiftAmount(amount domain.StarGiftAmount) tg.StarsAmountClass { + if amount.Currency == domain.StarGiftCurrencyTON { + return &tg.StarsTonAmount{Amount: amount.Amount} + } + return &tg.StarsAmount{Amount: amount.Amount, Nanos: amount.Nanos} +} + +func domainStarGiftAmount(amount tg.StarsAmountClass) (domain.StarGiftAmount, bool) { + switch value := amount.(type) { + case *tg.StarsAmount: + if value == nil { + return domain.StarGiftAmount{}, false + } + out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: value.Amount, Nanos: value.Nanos} + return out, out.Valid() + case *tg.StarsTonAmount: + if value == nil { + return domain.StarGiftAmount{}, false + } + out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: value.Amount} + return out, out.Valid() + default: + return domain.StarGiftAmount{}, false + } +} diff --git a/internal/rpc/payments_star_gifts.go b/internal/rpc/payments_star_gifts.go index 28a3a278..4fd8f057 100644 --- a/internal/rpc/payments_star_gifts.go +++ b/internal/rpc/payments_star_gifts.go @@ -2,7 +2,11 @@ package rpc import ( "context" + "crypto/rand" + "encoding/base64" "errors" + "fmt" + "strings" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tgerr" @@ -80,6 +84,21 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok { return r.starGiftUpgradePaymentForm(ctx, userID, inv) } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftTransfer); ok { + return r.starGiftTransferPaymentForm(ctx, userID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftResale); ok { + return r.starGiftResalePaymentForm(ctx, userID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftAuctionBid); ok { + return r.starGiftAuctionBidPaymentForm(ctx, userID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftPrepaidUpgrade); ok { + return r.starGiftPrepaidUpgradePaymentForm(ctx, userID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftDropOriginalDetails); ok { + return r.starGiftDropDetailsPaymentForm(ctx, userID, inv) + } inv, ok := req.Invoice.(*tg.InputInvoiceStarGift) if !ok { @@ -92,16 +111,13 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG if err != nil { return nil, err } - if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser { - // Channel upgrades remain blocked until they can advance channel pts and - // publish a durable channel update. Never collect a prepaid upgrade that - // the recipient cannot consume. - return nil, starGiftInvalidErr() - } gift, err := r.starGiftFromCatalog(ctx, inv.GiftID) if err != nil { return nil, err } + if gift.RequirePremium && !r.viewerPremium(ctx, userID) { + return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED") + } upgradeStars := int64(0) if inv.IncludeUpgrade { if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal { @@ -109,8 +125,21 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG } upgradeStars = gift.UpgradeStars } + giftMessage := "" + if m, ok := inv.GetMessage(); ok { + giftMessage = clampGiftMessage(m.Text) + } + now := int(r.clock.Now().Unix()) + form, err := r.deps.Gifts.IssuePurchaseForm(ctx, domain.StarGiftPurchaseForm{ + BuyerUserID: userID, To: peer, GiftID: gift.ID, RevisionID: gift.RevisionID, + IncludeUpgrade: inv.IncludeUpgrade, HideName: inv.HideName, Message: giftMessage, + ChargeStars: gift.Stars + upgradeStars, IssuedAt: now, ExpiresAt: now + 600, + }) + if err != nil { + return nil, starGiftLifecycleErr(err) + } return &tg.PaymentsPaymentFormStarGift{ - FormID: starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade), + FormID: form.FormID, Invoice: tg.Invoice{ Currency: "XTR", Prices: []tg.LabeledPrice{{Label: giftPriceLabel(gift), Amount: gift.Stars + upgradeStars}}, @@ -139,11 +168,29 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok { return r.sendStarGiftUpgradeForm(ctx, userID, req.FormID, inv) } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftTransfer); ok { + return r.sendStarGiftTransferForm(ctx, userID, req.FormID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftResale); ok { + return r.sendStarGiftResaleForm(ctx, userID, req.FormID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftAuctionBid); ok { + return r.sendStarGiftAuctionBidForm(ctx, userID, req.FormID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftPrepaidUpgrade); ok { + return r.sendStarGiftPrepaidUpgradeForm(ctx, userID, req.FormID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftDropOriginalDetails); ok { + return r.sendStarGiftDropDetailsForm(ctx, userID, req.FormID, inv) + } inv, ok := req.Invoice.(*tg.InputInvoiceStarGift) if !ok { return nil, notImplementedErr() } + if req.FormID == 0 { + return nil, formIDEmptyErr() + } peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer) if err != nil { return nil, err @@ -151,15 +198,9 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe if (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) || peer.ID == 0 { return nil, peerIDInvalidErr() } - if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser { - return nil, starGiftInvalidErr() - } if r.deps.Stars == nil || r.deps.Gifts == nil { return nil, notImplementedErr() } - if peer.Type == domain.PeerTypeUser && r.deps.Messages == nil { - return nil, notImplementedErr() - } if peer.Type == domain.PeerTypeChannel && r.deps.Channels == nil { return nil, notImplementedErr() } @@ -167,6 +208,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe if err != nil { return nil, err } + buyerPremium := r.viewerPremium(ctx, userID) + if gift.RequirePremium && !buyerPremium { + return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED") + } upgradeStars := int64(0) if inv.IncludeUpgrade { if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal { @@ -174,27 +219,58 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe } upgradeStars = gift.UpgradeStars } - if req.FormID != starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade) { - return nil, starsFormAmountMismatchErr() - } giftMessage := "" if m, ok := inv.GetMessage(); ok { giftMessage = clampGiftMessage(m.Text) } + now := int(r.clock.Now().Unix()) + purchaseReq := domain.StarGiftPurchaseRequest{BuyerUserID: userID, BuyerPremium: buyerPremium, To: peer, + GiftID: gift.ID, RevisionID: gift.RevisionID, IncludeUpgrade: inv.IncludeUpgrade, HideName: inv.HideName, Message: giftMessage, + ChargeStars: gift.Stars + upgradeStars, FormID: req.FormID, CommandKey: fmt.Sprintf("purchase:%d", req.FormID), Date: now, + OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)} + recipientBlocked := false + if peer.Type == domain.PeerTypeUser { + recipientBlocked, err = r.peerBlocksUser(ctx, userID, peer.ID) + if err != nil { + return nil, internalErr() + } + } + if capability, ok := r.deps.Gifts.(interface{ AtomicPurchaseConfigured() bool }); ok && !capability.AtomicPurchaseConfigured() { + if err := r.deps.Gifts.ValidatePurchaseForm(ctx, purchaseReq); err != nil { + return nil, starGiftLifecycleErr(err) + } + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + return r.sendStarGiftMemoryPurchase(ctx, userID, peer, gift, inv, giftMessage, upgradeStars) + } + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + purchaseReq.RecipientBlocked = recipientBlocked + result, err := r.deps.Gifts.Purchase(ctx, purchaseReq) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := r.starGiftSendUpdates(ctx, userID, result.Send) + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, result.Balance.Balance) + r.invalidateStarGiftOwner(peer) + return &tg.PaymentsPaymentResult{Updates: updates}, nil +} - // 1. Debit 送礼人(不足→BALANCE_TOO_LOW)。 +func (r *Router) sendStarGiftMemoryPurchase(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift, + inv *tg.InputInvoiceStarGift, giftMessage string, upgradeStars int64) (tg.PaymentsPaymentResultClass, error) { purchaseStars := gift.Stars + upgradeStars balance, err := r.deps.Stars.Debit(ctx, userID, purchaseStars, domain.StarsReasonGift, peer, "Star gift", gift.Title) if err != nil { return nil, starsErr(err) } - var updates *tg.Updates switch peer.Type { case domain.PeerTypeUser: updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars) case domain.PeerTypeChannel: - updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage) + updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars) default: err = domain.ErrStarGiftInvalid } @@ -202,18 +278,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe r.refundStarGift(ctx, userID, peer, gift, purchaseStars) return nil, internalErr() } - - // 4. 构建送礼人 Updates(服务消息 + updateStarsBalance)。 - if updates != nil { - updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}) - } else { - updates = &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}}, - Users: []tg.UserClass{}, - Chats: []tg.ChatClass{}, - Date: int(r.clock.Now().Unix()), - } + if updates == nil { + updates = emptyGiftUpdates(r.clock.Now().Unix()) } + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, balance.Balance) return &tg.PaymentsPaymentResult{Updates: updates}, nil } @@ -295,8 +363,16 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i } func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) { + prepaidUpgradeHash := "" + if prepaidUpgradeStars == 0 && gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal { + var token [32]byte + if _, err := rand.Read(token[:]); err != nil { + return nil, err + } + prepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:]) + } // 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。 - send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars) + send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars, prepaidUpgradeHash) if err != nil { return nil, err } @@ -312,6 +388,7 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i Unsaved: false, ConvertStars: gift.ConvertStars, PrepaidUpgradeStars: prepaidUpgradeStars, + PrepaidUpgradeHash: prepaidUpgradeHash, Message: message, }); err != nil { return nil, err @@ -324,38 +401,40 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i return tgPrivateMessageUpdates(send.SenderEvent, send.SenderMessage, 0, false, users, chats), nil } -func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) { +func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) { now := int(r.clock.Now().Unix()) sticker := gift.Sticker action := domain.ChannelMessageAction{ Type: domain.ChannelActionStarGift, StarGift: &domain.MessageStarGiftAction{ - GiftID: gift.ID, - Stars: gift.Stars, - ConvertStars: gift.ConvertStars, - Title: gift.Title, - Sticker: &sticker, - Message: message, - FromUserID: senderID, - NameHidden: hideName, - Saved: true, - CanUpgrade: false, - PrepaidUpgrade: false, - UpgradeStars: 0, + GiftID: gift.ID, + Stars: gift.Stars, + ConvertStars: gift.ConvertStars, + Title: gift.Title, + Sticker: &sticker, + Message: message, + FromUserID: senderID, + NameHidden: hideName, + Saved: true, + CanUpgrade: gift.UpgradeStars > 0, + PrepaidUpgrade: prepaidUpgradeStars > 0, + UpgradePriceStars: gift.UpgradeStars, + UpgradeStars: prepaidUpgradeStars, }, } savedID, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{ - Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, - FromUserID: senderID, - GiftID: gift.ID, - RevisionID: gift.RevisionID, - MsgID: 0, - SavedID: 0, - Date: now, - NameHidden: hideName, - Unsaved: false, - ConvertStars: gift.ConvertStars, - Message: message, + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + FromUserID: senderID, + GiftID: gift.ID, + RevisionID: gift.RevisionID, + MsgID: 0, + SavedID: 0, + Date: now, + NameHidden: hideName, + Unsaved: false, + ConvertStars: gift.ConvertStars, + PrepaidUpgradeStars: prepaidUpgradeStars, + Message: message, }) if err != nil { return nil, err @@ -375,7 +454,7 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID } // deliverStarGift 经 SendPrivateText 把 messageActionStarGift 服务消息投递到收礼人私聊。 -func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SendPrivateTextResult, error) { +func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64, prepaidUpgradeHash string) (domain.SendPrivateTextResult, error) { recipientBlocked, err := r.peerBlocksUser(ctx, senderID, recipientID) if err != nil { return domain.SendPrivateTextResult{}, err @@ -387,19 +466,21 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6 ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{ - GiftID: gift.ID, - Stars: gift.Stars, - ConvertStars: gift.ConvertStars, - Title: gift.Title, - Sticker: &sticker, - Message: message, - FromUserID: senderID, - PeerUserID: recipientID, - NameHidden: hideName, - Saved: true, - CanUpgrade: gift.UpgradeStars > 0, - PrepaidUpgrade: prepaidUpgradeStars > 0, - UpgradeStars: gift.UpgradeStars, + GiftID: gift.ID, + Stars: gift.Stars, + ConvertStars: gift.ConvertStars, + Title: gift.Title, + Sticker: &sticker, + Message: message, + FromUserID: senderID, + PeerUserID: recipientID, + NameHidden: hideName, + Saved: true, + CanUpgrade: gift.UpgradeStars > 0, + PrepaidUpgrade: prepaidUpgradeStars > 0, + PrepaidUpgradeHash: prepaidUpgradeHash, + UpgradePriceStars: gift.UpgradeStars, + UpgradeStars: prepaidUpgradeStars, }, }, } @@ -529,13 +610,15 @@ func (r *Router) onPaymentsSaveStarGift(ctx context.Context, req *tg.PaymentsSav return true, nil } -// onPaymentsConvertStarGift 把收到的礼物转换回 Stars(Credit + 标记 converted)。 +// onPaymentsConvertStarGift atomically destroys the regular gift and credits +// the owner-scoped internal Stars ledger. Channel proceeds never leak to the +// acting administrator's personal balance. func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSavedStarGiftClass) (bool, error) { userID, _, err := r.currentUserID(ctx) if err != nil { return false, internalErr() } - if r.deps.Gifts == nil || r.deps.Stars == nil { + if r.deps.Gifts == nil { return false, notImplementedErr() } dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref) @@ -545,10 +628,42 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave if !ok { return false, starGiftInvalidErr() } - if dref.Owner.Type == domain.PeerTypeChannel { - return false, notImplementedErr() + if err := r.ensureCanManageStarGiftOwner(ctx, userID, dref.Owner); err != nil { + return false, err } - saved, err := r.deps.Gifts.Convert(ctx, dref) + // The isolated memory RPC adapter intentionally has no aggregate store. Keep + // its conversion primitive usable for tests, but never use this split write + // path when the production lifecycle coordinator is configured. Channel + // balances have no memory adapter because crediting an administrator would + // violate owner-scoped accounting. + if converter, ok := r.deps.Gifts.(interface { + AtomicPurchaseConfigured() bool + Convert(context.Context, domain.SavedStarGiftRef) (domain.SavedStarGift, error) + }); ok && !converter.AtomicPurchaseConfigured() { + if dref.Owner.Type != domain.PeerTypeUser || dref.Owner.ID != userID { + return false, notImplementedErr() + } + updated, convertErr := converter.Convert(ctx, dref) + if convertErr != nil { + if errors.Is(convertErr, domain.ErrStarGiftNotFound) || errors.Is(convertErr, domain.ErrStarGiftAlreadyConverted) { + return false, starGiftInvalidErr() + } + return false, internalErr() + } + if updated.ConvertStars > 0 { + if _, creditErr := r.deps.Stars.Credit(ctx, userID, updated.ConvertStars, domain.StarsReasonGift, + dref.Owner, "Star gift conversion", "Converted Star Gift"); creditErr != nil { + return false, internalErr() + } + } + r.invalidateStarGiftOwnerProjection(dref.Owner) + return true, nil + } + result, err := r.deps.Gifts.ConvertAggregate(ctx, domain.StarGiftConvertRequest{ + ActorUserID: userID, + Ref: dref, + Date: int(r.clock.Now().Unix()), + }) if err != nil { switch { case errors.Is(err, domain.ErrStarGiftNotFound): @@ -559,15 +674,8 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave return false, internalErr() } } - if saved.ConvertStars > 0 { - fromPeer := domain.Peer{Type: domain.PeerTypeUser, ID: saved.FromUserID} - if _, err := r.deps.Stars.Credit(ctx, userID, saved.ConvertStars, domain.StarsReasonGift, fromPeer, "Star gift conversion", ""); err != nil { - r.log.Error("star gift convert credit failed", zap.Int64("user_id", userID), zap.Int("msg_id", dref.MsgID), zap.Error(err)) - return false, internalErr() - } - } // 转换移除一份展示礼物 → 失效 owner full 投影。 - r.invalidateStarGiftOwnerProjection(dref.Owner) + r.invalidateStarGiftOwnerProjection(result.Saved.Owner) return true, nil } @@ -622,6 +730,24 @@ func (r *Router) starGiftRefFromInput(ctx context.Context, userID int64, ref tg. return domain.SavedStarGiftRef{}, false, peerIDInvalidErr() } return domain.SavedStarGiftRef{Owner: owner, SavedID: v.SavedID}, true, nil + case *tg.InputSavedStarGiftSlug: + if v == nil || r.deps.Gifts == nil { + return domain.SavedStarGiftRef{}, false, nil + } + slug := strings.ToLower(strings.TrimSpace(v.Slug)) + if slug == "" || len(slug) > domain.MaxStarGiftSlugBytes { + return domain.SavedStarGiftRef{}, false, nil + } + unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, slug) + if err != nil { + return domain.SavedStarGiftRef{}, false, internalErr() + } + if !found || unique.Slug == "" || unique.Owner.ID == 0 || + (unique.Owner.Type != domain.PeerTypeUser && unique.Owner.Type != domain.PeerTypeChannel) { + return domain.SavedStarGiftRef{}, false, nil + } + resolved := domain.SavedStarGiftRef{Owner: unique.Owner, Slug: strings.ToLower(strings.TrimSpace(unique.Slug))} + return resolved, resolved.Valid(), nil default: return domain.SavedStarGiftRef{}, false, nil } @@ -718,11 +844,6 @@ func (r *Router) resolveStarGiftCollectibleAvailability(ctx context.Context, gif if gift.UniqueGiftID != 0 { continue } - if gift.Owner.Type != domain.PeerTypeUser { - // Channel upgrade RPCs are deliberately blocked until the channel pts - // aggregate exists, so do not advertise a dead-end action. - continue - } if _, ok := seen[gift.GiftID]; ok { continue } @@ -755,10 +876,24 @@ func tgStarGifts(catalog []domain.StarGift) []tg.StarGiftClass { // tgStarGift 把目录项投影为 tg.StarGift(Sticker 须为带 sticker 属性的有效 Document)。 func tgStarGift(g domain.StarGift) *tg.StarGift { gift := &tg.StarGift{ - ID: g.ID, - Sticker: tgDocument(g.Sticker), - Stars: g.Stars, - ConvertStars: g.ConvertStars, + Limited: g.Limited, SoldOut: g.SoldOut, Birthday: g.Birthday, + RequirePremium: g.RequirePremium, LimitedPerUser: g.LimitedPerUser, + PeerColorAvailable: g.PeerColorAvailable, Auction: g.Auction, + ID: g.ID, Sticker: tgDocument(g.Sticker), Stars: g.Stars, ConvertStars: g.ConvertStars, + } + if g.Limited { + gift.SetAvailabilityRemains(g.AvailabilityRemains) + gift.SetAvailabilityTotal(g.AvailabilityTotal) + } + if g.AvailabilityResale > 0 { + gift.SetAvailabilityResale(g.AvailabilityResale) + } + // sold_out, first_sale_date and last_sale_date share TL flags.1. The store + // retains sale timestamps for live gifts as operational facts, but exposing + // either timestamp would make every client decode the gift as sold out. + if g.SoldOut { + gift.SetFirstSaleDate(g.FirstSaleDate) + gift.SetLastSaleDate(g.LastSaleDate) } if g.Title != "" { gift.SetTitle(g.Title) @@ -766,6 +901,31 @@ func tgStarGift(g domain.StarGift) *tg.StarGift { if g.UpgradeStars > 0 && g.UpgradeIssued < g.UpgradeTotal { gift.SetUpgradeStars(g.UpgradeStars) } + if g.ResellMinStars > 0 { + gift.SetResellMinStars(g.ResellMinStars) + } + if releasedBy := tgPeer(g.ReleasedBy); releasedBy != nil { + gift.SetReleasedBy(releasedBy) + } + if g.LimitedPerUser { + gift.SetPerUserTotal(g.PerUserTotal) + gift.SetPerUserRemains(g.PerUserRemains) + } + if g.LockedUntilDate > 0 { + gift.SetLockedUntilDate(g.LockedUntilDate) + } + if g.Auction { + gift.SetAuctionSlug(g.AuctionSlug) + gift.SetGiftsPerRound(g.GiftsPerRound) + gift.SetAuctionStartDate(g.AuctionStartDate) + } + if g.UpgradeVariants > 0 { + gift.SetUpgradeVariants(g.UpgradeVariants) + } + if g.Background != nil { + gift.SetBackground(tg.StarGiftBackground{CenterColor: g.Background.CenterColor, + EdgeColor: g.Background.EdgeColor, TextColor: g.Background.TextColor}) + } return gift } @@ -787,6 +947,9 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC if in.Title != "" { gift.SetTitle(in.Title) } + if in.UpgradePriceStars > 0 { + gift.SetUpgradeStars(in.UpgradePriceStars) + } action := &tg.MessageActionStarGift{Gift: gift} if in.NameHidden { action.NameHidden = true @@ -799,12 +962,26 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC } action.CanUpgrade = in.CanUpgrade action.PrepaidUpgrade = in.PrepaidUpgrade + action.UpgradeSeparate = in.UpgradeSeparate + action.AuctionAcquired = in.AuctionAcquired if in.UpgradeStars > 0 { action.SetUpgradeStars(in.UpgradeStars) } if in.UpgradeMsgID > 0 { action.SetUpgradeMsgID(in.UpgradeMsgID) } + if in.PrepaidUpgradeHash != "" { + action.SetPrepaidUpgradeHash(in.PrepaidUpgradeHash) + } + if in.GiftMsgID > 0 { + action.SetGiftMsgID(in.GiftMsgID) + } + if in.GiftNum > 0 { + action.SetGiftNum(in.GiftNum) + } + if to := tgPeer(in.To); to != nil { + action.SetToID(to) + } if in.ConvertStars > 0 { action.SetConvertStars(in.ConvertStars) } @@ -889,6 +1066,9 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta item.SetUpgradeStars(g.PrepaidUpgradeStars) item.CanUpgrade = true } + if g.PrepaidUpgradeHash != "" && g.PrepaidUpgradeStars == 0 && canIssue { + item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash) + } } if g.PinnedOrder > 0 { item.PinnedToTop = true @@ -898,6 +1078,8 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta } if g.Unique != nil { item.SetGiftNum(g.Unique.Num) + } else if g.GiftNum > 0 { + item.SetGiftNum(g.GiftNum) } out = append(out, item) } @@ -944,24 +1126,6 @@ func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 { return ids } -func starGiftFormID(userID int64, peer domain.Peer, gift domain.StarGift) int64 { - return starGiftFormIDWithUpgrade(userID, peer, gift, false) -} - -func starGiftFormIDWithUpgrade(userID int64, peer domain.Peer, gift domain.StarGift, includeUpgrade bool) int64 { - id := userID*0x9e3779b1 ^ (gift.ID << 7) ^ (gift.RevisionID << 11) ^ (gift.Stars << 17) ^ (peer.ID << 23) ^ 0x5347494654 - if includeUpgrade { - id ^= gift.UpgradeStars<<29 ^ 0x55504752414445 - } - for _, ch := range string(peer.Type) { - id = id*131 + int64(ch) - } - if id == 0 { - id = 0x5347 - } - return id -} - func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 { id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253 for _, ch := range currency { diff --git a/internal/rpc/payments_star_gifts_rpc_test.go b/internal/rpc/payments_star_gifts_rpc_test.go index 2080815f..80c8b682 100644 --- a/internal/rpc/payments_star_gifts_rpc_test.go +++ b/internal/rpc/payments_star_gifts_rpc_test.go @@ -21,6 +21,10 @@ import ( ) func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) { + return starGiftTestRouterWithPremium(t, false) +} + +func starGiftTestRouterWithPremium(t *testing.T, requirePremium bool) (*Router, domain.User, domain.User, domain.StarGift) { t.Helper() ctx := context.Background() users := memory.NewUserStore() @@ -36,7 +40,7 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain t.Fatalf("create recipient: %v", err) } gift := domain.StarGift{ - ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake", + ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake", RequirePremium: requirePremium, Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, } giftStore := memory.NewStarGiftStore() @@ -52,6 +56,33 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain return r, sender, recipient, gift } +func TestStarGiftPurchaseRequiresActivePremium(t *testing.T) { + r, sender, recipient, gift := starGiftTestRouterWithPremium(t, true) + ctx := WithUserID(context.Background(), sender.ID) + inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID} + if _, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}); !tgerr.Is(err, "PREMIUM_ACCOUNT_REQUIRED") { + t.Fatalf("non-premium gift form err = %v, want PREMIUM_ACCOUNT_REQUIRED", err) + } + premium, ok := r.deps.Users.(UserPremiumService) + if !ok { + t.Fatalf("users service %T does not implement premium grants", r.deps.Users) + } + if _, err := premium.GrantPremium(context.Background(), sender.ID, 1); err != nil { + t.Fatalf("grant premium: %v", err) + } + formRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("premium gift form: %v", err) + } + form, ok := formRes.(*tg.PaymentsPaymentFormStarGift) + if !ok { + t.Fatalf("premium gift form = %T", formRes) + } + if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); err != nil { + t.Fatalf("premium gift purchase: %v", err) + } +} + type uniqueGiftRPCService struct { GiftsService unique domain.UniqueStarGift @@ -61,8 +92,139 @@ func (s *uniqueGiftRPCService) UniqueBySlug(_ context.Context, slug string) (dom return s.unique, slug == s.unique.Slug, nil } +type craftStarGiftRPCService struct { + GiftsService + uniques map[string]domain.UniqueStarGift + saved map[int64]domain.SavedStarGift + result domain.StarGiftCraftResult + craftReq domain.StarGiftCraftRequest + craftCall int +} + +func (s *craftStarGiftRPCService) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) { + unique, ok := s.uniques[slug] + return unique, ok, nil +} + +func (s *craftStarGiftRPCService) GetSaved(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) { + for _, saved := range s.saved { + if saved.Owner != ref.Owner { + continue + } + if ref.Slug != "" { + unique, ok := s.uniques[ref.Slug] + if ok && unique.ID == saved.UniqueGiftID { + return saved, true, nil + } + continue + } + if saved.MsgID == ref.MsgID { + return saved, true, nil + } + } + return domain.SavedStarGift{}, false, nil +} + +func (s *craftStarGiftRPCService) Craft(_ context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) { + s.craftCall++ + s.craftReq = req + return s.result, nil +} + +func TestCraftStarGiftAcceptsOfficialSlugAndCanonicalizesAliases(t *testing.T) { + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 7102} + service := &craftStarGiftRPCService{ + uniques: map[string]domain.UniqueStarGift{ + "official-8001-2": {ID: 902, Slug: "official-8001-2", Owner: owner, SourceSavedGiftID: 52}, + }, + saved: map[int64]domain.SavedStarGift{ + 50: {ID: 50, Owner: owner, MsgID: 115, UniqueGiftID: 901, UpgradeMsgID: 116}, + 52: {ID: 52, Owner: owner, MsgID: 111, UniqueGiftID: 902, UpgradeMsgID: 112}, + }, + result: domain.StarGiftCraftResult{Chance: 500, SourceEdits: []domain.EditedMessageForUser{{ + UserID: owner.ID, + Message: domain.Message{ID: 116, OwnerUserID: owner.ID, Peer: owner, From: owner, Date: 100}, + Event: domain.UpdateEvent{UserID: owner.ID, Type: domain.UpdateEventEditMessage, Pts: 41, PtsCount: 1, + Date: 100, Message: domain.Message{ID: 116, OwnerUserID: owner.ID, Peer: owner, From: owner, Date: 100}}, + }}}, + } + r := New(Config{DC: 2}, Deps{Gifts: service}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), owner.ID) + updates, err := r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{ + &tg.InputSavedStarGiftUser{MsgID: 115}, + &tg.InputSavedStarGiftSlug{Slug: "OFFICIAL-8001-2"}, + }}) + if err != nil || updates == nil { + t.Fatalf("craft mixed official refs: updates=%T err=%v", updates, err) + } + if service.craftCall != 1 || service.craftReq.CommandKey != "rpc:50,52" || len(service.craftReq.Refs) != 2 || + service.craftReq.Refs[1].Slug != "official-8001-2" { + t.Fatalf("craft request = %+v calls=%d", service.craftReq, service.craftCall) + } + full, ok := updates.(*tg.Updates) + if !ok || len(full.Updates) != 2 { + t.Fatalf("craft failure updates = %T %#v", updates, updates) + } + if edit, ok := full.Updates[0].(*tg.UpdateEditMessage); !ok || edit.Pts != 41 || edit.PtsCount != 1 { + t.Fatalf("craft failure source update = %T %#v", full.Updates[0], full.Updates[0]) + } + if _, ok := full.Updates[1].(*tg.UpdateStarGiftCraftFail); !ok { + t.Fatalf("craft terminal update = %T %#v", full.Updates[1], full.Updates[1]) + } + + service.craftCall = 0 + _, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{ + &tg.InputSavedStarGiftUser{MsgID: 111}, + &tg.InputSavedStarGiftSlug{Slug: "official-8001-2"}, + }}) + if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 { + t.Fatalf("duplicate aliases err=%v craft calls=%d", err, service.craftCall) + } + + _, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{ + &tg.InputSavedStarGiftUser{MsgID: 116}, + }}) + if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 { + t.Fatalf("upgrade message id accepted as gift identity: err=%v craft calls=%d", err, service.craftCall) + } +} + +type upgradeReplayRPCService struct { + GiftsService + saved domain.SavedStarGift + receipt domain.StarGiftUpgradeReceipt + result domain.StarGiftUpgradeResult + upgradeCalls int + previewCalls int + lastRequest domain.StarGiftUpgradeRequest +} + +func (s *upgradeReplayRPCService) GetSaved(_ context.Context, _ domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) { + return s.saved, true, nil +} + +func (s *upgradeReplayRPCService) UpgradeReceipt(_ context.Context, userID int64, _ string) (domain.StarGiftUpgradeReceipt, bool, error) { + if userID != s.receipt.UserID { + return domain.StarGiftUpgradeReceipt{}, false, nil + } + return s.receipt, true, nil +} + +func (s *upgradeReplayRPCService) CollectiblePreview(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) { + s.previewCalls++ + return domain.StarGiftUpgradePreview{}, false, nil +} + +func (s *upgradeReplayRPCService) Upgrade(_ context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) { + s.upgradeCalls++ + s.lastRequest = req + return s.result, nil +} + func collectibleRPCAttribute(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute { - attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityPermille: 1000} + attribute := domain.StarGiftCollectibleAttribute{ + Kind: kind, Name: name, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + } if kind == domain.StarGiftCollectibleBackdrop { attribute.BackdropID = int(id) attribute.CenterColor = 0x112233 @@ -148,6 +310,120 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA } } +func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing.T) { + ordinary, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{ + GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, UpgradePriceStars: 75, + }).(*tg.MessageActionStarGift) + if !ok { + t.Fatalf("ordinary action = %T", ordinary) + } + ordinaryGift, ok := ordinary.Gift.(*tg.StarGift) + if !ok { + t.Fatalf("ordinary inner gift = %T", ordinary.Gift) + } + if price, set := ordinaryGift.GetUpgradeStars(); !set || price != 75 { + t.Fatalf("ordinary inner upgrade_stars = %d set=%v, want paid price 75", price, set) + } + if amount, set := ordinary.GetUpgradeStars(); set || amount != 0 || ordinary.PrepaidUpgrade { + t.Fatalf("ordinary outer upgrade_stars = %d set=%v prepaid=%v, want absent", amount, set, ordinary.PrepaidUpgrade) + } + + prepaid, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{ + GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, PrepaidUpgrade: true, + UpgradePriceStars: 75, UpgradeStars: 75, + }).(*tg.MessageActionStarGift) + if !ok { + t.Fatalf("prepaid action = %T", prepaid) + } + if amount, set := prepaid.GetUpgradeStars(); !set || amount != 75 || !prepaid.PrepaidUpgrade { + t.Fatalf("prepaid outer upgrade_stars = %d set=%v prepaid=%v, want 75", amount, set, prepaid.PrepaidUpgrade) + } + upgraded, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{ + GiftID: 8001, Stars: 50, ConvertStars: 25, UpgradeMsgID: 88, + }).(*tg.MessageActionStarGift) + if !ok { + t.Fatalf("upgraded action = %T", upgraded) + } + if msgID, set := upgraded.GetUpgradeMsgID(); !set || msgID != 88 { + t.Fatalf("upgrade_msg_id = %d set=%v, want 88", msgID, set) + } + for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} { + wire := &bin.Buffer{} + if err := tlprofile.EncodeObject(profile, ordinary, wire); err != nil { + t.Fatalf("encode Layer %d ordinary action: %v", profile, err) + } + decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("decode Layer %d ordinary action: %v", profile, err) + } + decoded, ok := decodedObject.(*tg.MessageActionStarGift) + if !ok { + t.Fatalf("decode Layer %d action = %T", profile, decodedObject) + } + inner, ok := decoded.Gift.(*tg.StarGift) + if !ok || inner.UpgradeStars != 75 || decoded.UpgradeStars != 0 || decoded.PrepaidUpgrade { + t.Fatalf("Layer %d ordinary action lost paid/prepaid split: %#v", profile, decoded) + } + + upgradedWire := &bin.Buffer{} + if err := tlprofile.EncodeObject(profile, upgraded, upgradedWire); err != nil { + t.Fatalf("encode Layer %d upgraded action: %v", profile, err) + } + decodedUpgradedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: upgradedWire.Buf}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("decode Layer %d upgraded action: %v", profile, err) + } + decodedUpgraded, ok := decodedUpgradedObject.(*tg.MessageActionStarGift) + if !ok || !decodedUpgraded.Upgraded || decodedUpgraded.UpgradeMsgID != 88 || decodedUpgraded.CanUpgrade { + t.Fatalf("Layer %d upgraded action lost transition flags: %#v", profile, decodedUpgradedObject) + } + } +} + +func TestStarGiftUpgradeRPCReplaysCommittedReceiptAfterTerminalTransition(t *testing.T) { + r, sender, owner, gift := starGiftTestRouter(t) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + saved := domain.SavedStarGift{ + ID: 47, Owner: ownerPeer, FromUserID: sender.ID, GiftID: gift.ID, RevisionID: gift.RevisionID, + MsgID: 105, UniqueGiftID: 9200000000000004, + } + result := domain.StarGiftUpgradeResult{ + Saved: saved, Unique: domain.UniqueStarGift{ID: saved.UniqueGiftID, GiftID: gift.ID, Owner: ownerPeer}, + Balance: domain.StarsBalance{UserID: owner.ID, Balance: 1000}, Duplicate: true, + Send: domain.SendPrivateTextResult{ + RecipientMessage: domain.Message{ID: 107, OwnerUserID: owner.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Date: 1700000001}, + RecipientEvent: domain.UpdateEvent{UserID: owner.ID, Pts: 42, PtsCount: 1, Date: 1700000001}, + }, + } + service := &upgradeReplayRPCService{GiftsService: r.deps.Gifts, saved: saved, result: result, + receipt: domain.StarGiftUpgradeReceipt{UserID: owner.ID, SourceSavedGiftID: saved.ID, + UniqueGiftID: saved.UniqueGiftID, RequirePrepaid: true, KeepOriginalDetails: true, BalanceAfter: 1000}} + r.deps.Gifts = service + ctx := WithUserID(context.Background(), owner.ID) + if _, err := r.onPaymentsUpgradeStarGift(ctx, &tg.PaymentsUpgradeStarGiftRequest{ + KeepOriginalDetails: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: saved.MsgID}, + }); err != nil { + t.Fatalf("replay prepaid upgrade after terminal transition: %v", err) + } + if service.upgradeCalls != 1 || service.previewCalls != 0 || !service.lastRequest.RequirePrepaid || service.lastRequest.ChargeStars != 0 { + t.Fatalf("prepaid replay calls=%d preview=%d req=%+v", service.upgradeCalls, service.previewCalls, service.lastRequest) + } + + const paidFormID int64 = -7611777087885039132 + service.receipt = domain.StarGiftUpgradeReceipt{UserID: owner.ID, SourceSavedGiftID: saved.ID, + FormID: paidFormID, UniqueGiftID: saved.UniqueGiftID, ChargeStars: 25, + KeepOriginalDetails: true, BalanceAfter: 975} + service.upgradeCalls, service.previewCalls = 0, 0 + if _, err := r.sendStarGiftUpgradeForm(ctx, owner.ID, paidFormID, &tg.InputInvoiceStarGiftUpgrade{ + KeepOriginalDetails: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: saved.MsgID}, + }); err != nil { + t.Fatalf("replay paid upgrade after terminal transition: %v", err) + } + if service.upgradeCalls != 1 || service.previewCalls != 0 || service.lastRequest.ChargeStars != 25 || service.lastRequest.FormID != paidFormID { + t.Fatalf("paid replay calls=%d preview=%d req=%+v", service.upgradeCalls, service.previewCalls, service.lastRequest) + } +} + func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *testing.T) { r, sender, owner, gift := starGiftTestRouter(t) ctx := context.Background() @@ -157,11 +433,15 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test t.Fatalf("gift service = %T", r.deps.Gifts) } model := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8101, "Aurora") + crafted := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8103, "Crafted Aurora") + crafted.Crafted = true + crafted.RarityKind = domain.StarGiftRarityLegendary + crafted.RarityPermille = 0 pattern := collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8102, "Orbit") backdrop := collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 1, "Midnight") if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 500, SlugPrefix: "cake", - Models: []domain.StarGiftCollectibleAttribute{model}, Patterns: []domain.StarGiftCollectibleAttribute{pattern}, + Models: []domain.StarGiftCollectibleAttribute{model, crafted}, Patterns: []domain.StarGiftCollectibleAttribute{pattern}, Backdrops: []domain.StarGiftCollectibleAttribute{backdrop}, Actor: "test", CommandID: "collectible-rpc", }); err != nil { t.Fatalf("publish collectible pool: %v", err) @@ -177,6 +457,17 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test if err != nil || len(preview.SampleAttributes) != 3 { t.Fatalf("upgrade preview = %#v err %v", preview, err) } + attributes, err := r.onPaymentsGetStarGiftUpgradeAttributes(ownerCtx, gift.ID) + if err != nil || len(attributes.Attributes) != 4 { + t.Fatalf("upgrade attributes = %#v err %v", attributes, err) + } + craftedTG, ok := attributes.Attributes[1].(*tg.StarGiftAttributeModel) + if !ok || !craftedTG.Crafted { + t.Fatalf("crafted attribute = %T %#v", attributes.Attributes[1], attributes.Attributes[1]) + } + if _, ok := craftedTG.Rarity.(*tg.StarGiftAttributeRarityLegendary); !ok { + t.Fatalf("crafted rarity = %T", craftedTG.Rarity) + } invoice := &tg.InputInvoiceStarGiftUpgrade{Stargift: &tg.InputSavedStarGiftUser{MsgID: 444}} formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice}) if err != nil { @@ -208,7 +499,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test message := domain.Message{Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ - Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, Upgrade: true, Saved: true, + Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, SavedID: 444, Upgrade: true, Saved: true, }, }}} action, ok := tgMessageServiceAction(message).(*tg.MessageActionStarGiftUnique) @@ -224,6 +515,9 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test } else if user, ok := peer.(*tg.PeerUser); !ok || user.UserID != owner.ID { t.Fatalf("unique service action peer = %#v", peer) } + if savedID, ok := action.GetSavedID(); !ok || savedID != 444 { + t.Fatalf("unique service action saved_id = %d set=%v, want 444", savedID, ok) + } for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} { responseWire := &bin.Buffer{} if err := tlprofile.EncodeObject(profile, uniqueResponse, responseWire); err != nil { @@ -254,7 +548,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test if !ok { t.Fatalf("decode Layer %d unique action type = %T", profile, decodedActionObject) } - if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedActionGift.Slug != unique.Slug { + if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedAction.SavedID != 444 || decodedActionGift.Slug != unique.Slug { t.Fatalf("Layer %d unique action lost fields: %#v", profile, decodedAction) } } @@ -556,10 +850,15 @@ func TestStarGiftChannelSaga(t *testing.T) { }); err != nil { t.Fatalf("publish channel collectible pool: %v", err) } - if _, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{ + upgradeFormRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{ Peer: channelPeer, GiftID: gift.ID, IncludeUpgrade: true, - }}); err == nil { - t.Fatal("channel include_upgrade must be rejected while channel upgrade is blocked") + }}) + if err != nil { + t.Fatalf("getPaymentForm(channel include_upgrade): %v", err) + } + upgradeForm, ok := upgradeFormRes.(*tg.PaymentsPaymentFormStarGift) + if !ok || len(upgradeForm.Invoice.Prices) != 1 || upgradeForm.Invoice.Prices[0].Amount != gift.Stars+75 { + t.Fatalf("channel include_upgrade form = %T %+v, want total %d", upgradeFormRes, upgradeFormRes, gift.Stars+75) } inv := &tg.InputInvoiceStarGift{ Peer: channelPeer, @@ -612,8 +911,8 @@ func TestStarGiftChannelSaga(t *testing.T) { if savedRes.Count != 1 || len(savedRes.Gifts) != 1 { t.Fatalf("channel saved gifts = count %d len %d, want 1/1", savedRes.Count, len(savedRes.Gifts)) } - if savedRes.Gifts[0].CanUpgrade { - t.Fatal("channel saved gift must not advertise upgrade while channel aggregate is blocked") + if !savedRes.Gifts[0].CanUpgrade { + t.Fatal("channel saved gift must advertise upgrade when a collectible pool is available") } savedID, ok := savedRes.Gifts[0].GetSavedID() if !ok || savedID <= 0 { @@ -728,8 +1027,12 @@ func TestStarGiftInsufficientBalance(t *testing.T) { }, zaptest.NewLogger(t), clock.System) senderCtx := WithUserID(ctx, sender.ID) inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID} - peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID} - if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: starGiftFormID(sender.ID, peer, gift), Invoice: inv}); err == nil { + formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("get expensive gift form: %v", err) + } + form := formRes.(*tg.PaymentsPaymentFormStarGift) + if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); !tgerr.Is(err, "BALANCE_TOO_LOW") { t.Fatalf("over-budget gift should error BALANCE_TOO_LOW") } // 余额未变。 @@ -738,22 +1041,60 @@ func TestStarGiftInsufficientBalance(t *testing.T) { } } -func TestStarGiftFormBindsCatalogRevisionAndPrice(t *testing.T) { +func TestStarGiftPurchaseFormsAreFreshAndBindPurpose(t *testing.T) { r, sender, recipient, gift := starGiftTestRouter(t) ctx := WithUserID(context.Background(), sender.ID) - peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID} - base := starGiftFormID(sender.ID, peer, gift) - changedRevision := gift - changedRevision.RevisionID++ - changedPrice := gift - changedPrice.Stars++ - changedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID + 1} - if base == starGiftFormID(sender.ID, peer, changedRevision) || base == starGiftFormID(sender.ID, peer, changedPrice) || base == starGiftFormID(sender.ID, changedPeer, gift) { - t.Fatal("star gift form id must bind revision, price and recipient") - } inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID} - if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: base + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") { - t.Fatalf("bad form err=%v", err) + firstRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("first form: %v", err) + } + secondRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("second form: %v", err) + } + first := firstRes.(*tg.PaymentsPaymentFormStarGift) + second := secondRes.(*tg.PaymentsPaymentFormStarGift) + if first.FormID == 0 || second.FormID == 0 || first.FormID == second.FormID { + t.Fatalf("fresh form ids = %d/%d, want distinct non-zero TL longs", first.FormID, second.FormID) + } + if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: first.FormID + second.FormID, Invoice: inv}); !tgerr.Is(err, "FORM_EXPIRED") { + t.Fatalf("unknown form err=%v, want FORM_EXPIRED", err) + } + tampered := *inv + tampered.HideName = true + if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: first.FormID, Invoice: &tampered}); !tgerr.Is(err, "PURPOSE_INVALID") { + t.Fatalf("tampered form err=%v, want PURPOSE_INVALID", err) + } +} + +func TestStarGiftCanPurchaseSameCatalogGiftTwice(t *testing.T) { + r, sender, recipient, gift := starGiftTestRouter(t) + ctx := WithUserID(context.Background(), sender.ID) + inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID} + var formIDs []int64 + for i := 0; i < 2; i++ { + formRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("get form %d: %v", i, err) + } + form := formRes.(*tg.PaymentsPaymentFormStarGift) + formIDs = append(formIDs, form.FormID) + if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); err != nil { + t.Fatalf("purchase %d: %v", i, err) + } + } + if formIDs[0] == formIDs[1] { + t.Fatalf("repeated purchase reused form id %d", formIDs[0]) + } + saved, err := r.onPaymentsGetSavedStarGifts(WithUserID(context.Background(), recipient.ID), &tg.PaymentsGetSavedStarGiftsRequest{ + Peer: &tg.InputPeerSelf{}, Limit: 10, + }) + if err != nil { + t.Fatalf("get recipient gifts: %v", err) + } + if saved.Count != 2 || len(saved.Gifts) != 2 { + t.Fatalf("recipient gifts = count %d len %d, want two independent gifts", saved.Count, len(saved.Gifts)) } } diff --git a/internal/rpc/payments_stars_rpc_test.go b/internal/rpc/payments_stars_rpc_test.go index 3b04e5a5..6493a39b 100644 --- a/internal/rpc/payments_stars_rpc_test.go +++ b/internal/rpc/payments_stars_rpc_test.go @@ -98,3 +98,113 @@ func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) { } _ = domain.DefaultStarsStartingGrant } + +type channelLedgerGifts struct { + GiftsService + starsBalance int64 + tonBalance int64 + starsPage domain.StarsTransactionPage + tonPage domain.TonTransactionPage +} + +func (s *channelLedgerGifts) ChannelStarsBalance(context.Context, int64) (int64, error) { + return s.starsBalance, nil +} + +func (s *channelLedgerGifts) ChannelStarsTransactions(context.Context, int64, string, int) (domain.StarsTransactionPage, error) { + return s.starsPage, nil +} + +func (s *channelLedgerGifts) ChannelTonBalance(context.Context, int64) (int64, error) { + return s.tonBalance, nil +} + +func (s *channelLedgerGifts) ChannelTonTransactions(context.Context, int64, string, int) (domain.TonTransactionPage, error) { + return s.tonPage, nil +} + +type channelLedgerChannels struct { + ChannelsService + view domain.ChannelView +} + +func (s *channelLedgerChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) { + return s.view, nil +} + +func (s *channelLedgerChannels) GetChannels(context.Context, int64, []int64) ([]domain.ChannelView, error) { + return []domain.ChannelView{s.view}, nil +} + +func TestPaymentsStarsLedgerUsesRequestedChannelOwner(t *testing.T) { + const viewerID, channelID int64 = 1000000001, 2000000001 + view := domain.ChannelView{ + Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true, CreatorUserID: viewerID}, + Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive}, + } + gifts := &channelLedgerGifts{ + starsBalance: 20, + tonBalance: 900, + starsPage: domain.StarsTransactionPage{Balance: 20, Transactions: []domain.StarsTransaction{{ + ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, Amount: 20, Date: 10, Reason: domain.StarsReasonGift, + }}}, + tonPage: domain.TonTransactionPage{Balance: 900, Transactions: []domain.TonTransaction{{ + ID: 2, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000002}, GiftID: 9, Amount: 900, Date: 11, Reason: domain.StarsReasonGiftResale, + }}}, + } + r := New(Config{}, Deps{Gifts: gifts, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), viewerID) + peer := &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash} + + status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: peer}) + if err != nil { + t.Fatalf("get channel stars status: %v", err) + } + if amount, ok := status.Balance.(*tg.StarsAmount); !ok || amount.Amount != 20 || len(status.Chats) != 1 { + t.Fatalf("channel stars status = %+v chats=%d", status.Balance, len(status.Chats)) + } + revenue, err := r.onPaymentsGetStarsRevenueStats(ctx, &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer}) + if err != nil { + t.Fatalf("get channel stars revenue: %v", err) + } + if current, ok := revenue.Status.CurrentBalance.(*tg.StarsAmount); !ok || current.Amount != 20 { + t.Fatalf("channel stars revenue current = %+v", revenue.Status.CurrentBalance) + } + if overall, ok := revenue.Status.OverallRevenue.(*tg.StarsAmount); !ok || overall.Amount != 20 || revenue.Status.WithdrawalEnabled { + t.Fatalf("channel stars revenue overall = %+v withdrawal=%v", revenue.Status.OverallRevenue, revenue.Status.WithdrawalEnabled) + } + + txnReq := &tg.PaymentsGetStarsTransactionsRequest{Peer: peer, Limit: 20} + txnReq.SetTon(true) + transactions, err := r.onPaymentsGetStarsTransactions(ctx, txnReq) + if err != nil { + t.Fatalf("get channel ton transactions: %v", err) + } + history, ok := transactions.GetHistory() + if amount, amountOK := transactions.Balance.(*tg.StarsTonAmount); !amountOK || amount.Amount != 900 || !ok || len(history) != 1 || !history[0].StargiftResale { + t.Fatalf("channel ton transactions = balance=%+v history=%+v", transactions.Balance, history) + } + revenueReq := &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer} + revenueReq.SetTon(true) + tonRevenue, err := r.onPaymentsGetStarsRevenueStats(ctx, revenueReq) + if err != nil { + t.Fatalf("get channel ton revenue: %v", err) + } + if current, ok := tonRevenue.Status.CurrentBalance.(*tg.StarsTonAmount); !ok || current.Amount != 900 { + t.Fatalf("channel ton revenue current = %+v", tonRevenue.Status.CurrentBalance) + } +} + +func TestPaymentsStarsLedgerRejectsNonAdminChannelReader(t *testing.T) { + const viewerID, channelID int64 = 1000000001, 2000000001 + view := domain.ChannelView{ + Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true}, + Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive}, + } + r := New(Config{}, Deps{Gifts: &channelLedgerGifts{}, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), viewerID) + _, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}}) + if err == nil { + t.Fatal("non-admin channel ledger read unexpectedly succeeded") + } +} diff --git a/internal/store/memory/star_gift.go b/internal/store/memory/star_gift.go index b6ab41f6..348ee1ef 100644 --- a/internal/store/memory/star_gift.go +++ b/internal/store/memory/star_gift.go @@ -96,6 +96,10 @@ func (s *StarGiftStore) CatalogRevision(_ context.Context, revisionID int64) (do func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) { s.mu.Lock() defer s.mu.Unlock() + return s.createCatalogRevisionLocked(write) +} + +func (s *StarGiftStore) createCatalogRevisionLocked(write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) { giftID := write.GiftID if giftID == 0 { s.nextGiftID++ @@ -104,7 +108,21 @@ func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.St return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftNotFound } s.nextRevID++ - gift := domain.StarGift{ID: giftID, RevisionID: s.nextRevID, Stars: write.Stars, ConvertStars: write.ConvertStars, Title: write.Title, Sticker: write.Document} + gift := domain.StarGift{ + ID: giftID, RevisionID: s.nextRevID, Stars: write.Stars, ConvertStars: write.ConvertStars, + Title: write.Title, Sticker: write.Document, + Limited: write.Limited, SoldOut: write.SoldOut, Birthday: write.Birthday, + RequirePremium: write.RequirePremium, LimitedPerUser: write.LimitedPerUser, + PeerColorAvailable: write.PeerColorAvailable, Auction: write.Auction, + AvailabilityRemains: write.AvailabilityRemains, AvailabilityTotal: write.AvailabilityTotal, + AvailabilityResale: write.AvailabilityResale, FirstSaleDate: write.FirstSaleDate, + LastSaleDate: write.LastSaleDate, ResellMinStars: write.ResellMinStars, + ReleasedBy: write.ReleasedBy, PerUserTotal: write.PerUserTotal, + PerUserRemains: write.PerUserTotal, LockedUntilDate: write.LockedUntilDate, + AuctionSlug: write.AuctionSlug, GiftsPerRound: write.GiftsPerRound, + AuctionStartDate: write.AuctionStartDate, UpgradeVariants: write.UpgradeVariants, + Background: cloneStarGiftBackground(write.Background), + } s.catalog[giftID] = gift s.revisions[gift.RevisionID] = gift s.enabled[giftID] = write.Enabled @@ -113,6 +131,45 @@ func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.St return domain.StarGiftCatalogEntry{Gift: gift, Enabled: write.Enabled, SortOrder: write.SortOrder}, nil } +func cloneStarGiftBackground(value *domain.StarGiftBackground) *domain.StarGiftBackground { + if value == nil { + return nil + } + copy := *value + return © +} + +func (s *StarGiftStore) CreateCatalogBundle(_ context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + if write.Collectible != nil { + collectibleWrite := *write.Collectible + collectibleWrite.GiftID = write.Catalog.GiftID + if collectibleWrite.GiftID == 0 { + collectibleWrite.GiftID = s.nextGiftID + 1 + } + if err := domain.ValidateStarGiftCollectibleWrite(collectibleWrite); err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + } + entry, err := s.createCatalogRevisionLocked(write.Catalog) + if err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + result := domain.StarGiftCatalogBundleResult{Catalog: entry} + if write.Collectible != nil { + collectibleWrite := *write.Collectible + collectibleWrite.GiftID = entry.Gift.ID + revision, err := s.publishCollectibleRevisionLocked(collectibleWrite) + if err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + result.Collectible = &revision + result.Catalog.Gift = s.catalog[entry.Gift.ID] + } + return result, nil +} + func (s *StarGiftStore) SetCatalogEnabled(_ context.Context, giftID int64, enabled bool) (bool, error) { s.mu.Lock() defer s.mu.Unlock() @@ -148,6 +205,10 @@ func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write doma } s.mu.Lock() defer s.mu.Unlock() + return s.publishCollectibleRevisionLocked(write) +} + +func (s *StarGiftStore) publishCollectibleRevisionLocked(write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) { if _, ok := s.catalog[write.GiftID]; !ok { return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftNotFound } @@ -156,7 +217,8 @@ func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write doma ID: previous.ID + 1, GiftID: write.GiftID, Revision: previous.Revision + 1, UpgradeStars: write.UpgradeStars, SupplyTotal: write.SupplyTotal, SlugPrefix: strings.ToLower(strings.TrimSpace(write.SlugPrefix)), Published: true, - CreatedBy: write.Actor, + CreatedBy: write.Actor, + OfficialGiftID: write.OfficialGiftID, SourceManifestSHA256: append([]byte(nil), write.SourceManifestSHA256...), } if revision.ID == 1 { revision.ID = write.GiftID*1000 + 1 @@ -275,6 +337,7 @@ func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (in gift.SavedID = gift.ID } gift.Converted = false + gift.LifecycleStatus = domain.StarGiftLifecycleActive s.gifts = append(s.gifts, gift) return gift.ID, nil } @@ -297,7 +360,7 @@ func (s *StarGiftStore) ListByOwnerFiltered(_ context.Context, filter domain.Sav defer s.mu.Unlock() matched := make([]domain.SavedStarGift, 0) for _, g := range s.gifts { - if g.Owner != owner || g.Converted { + if g.Owner != owner || !g.LifecycleStatus.Live() { continue } if filter.ExcludeUnsaved && g.Unsaved { @@ -370,7 +433,7 @@ func (s *StarGiftStore) ResolveSavedIDs(_ context.Context, owner domain.Peer, re } var id int64 for _, gift := range s.gifts { - if savedStarGiftMatchesRef(gift, ref) && !gift.Converted { + if s.savedStarGiftMatchesRef(gift, ref) && gift.LifecycleStatus.Live() { id = gift.ID break } @@ -394,7 +457,7 @@ func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef) s.mu.Lock() defer s.mu.Unlock() for _, g := range s.gifts { - if savedStarGiftMatchesRef(g, ref) { + if s.savedStarGiftMatchesRef(g, ref) { return g, true, nil } } @@ -409,7 +472,7 @@ func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int, defer s.mu.Unlock() n := 0 for _, g := range s.gifts { - if g.Owner == owner && !g.Converted && !g.Unsaved { + if g.Owner == owner && g.LifecycleStatus.Live() && !g.Unsaved { n++ } } @@ -423,7 +486,7 @@ func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRe s.mu.Lock() defer s.mu.Unlock() for i := range s.gifts { - if savedStarGiftMatchesRef(s.gifts[i], ref) && !s.gifts[i].Converted { + if s.savedStarGiftMatchesRef(s.gifts[i], ref) && s.gifts[i].LifecycleStatus.Live() { s.gifts[i].Unsaved = unsaved return true, nil } @@ -438,7 +501,7 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif s.mu.Lock() defer s.mu.Unlock() for i := range s.gifts { - if savedStarGiftMatchesRef(s.gifts[i], ref) { + if s.savedStarGiftMatchesRef(s.gifts[i], ref) { if s.gifts[i].UniqueGiftID != 0 { return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyUpgraded } @@ -446,6 +509,7 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyConverted } s.gifts[i].Converted = true + s.gifts[i].LifecycleStatus = domain.StarGiftLifecycleConverted s.gifts[i].Unsaved = true s.gifts[i].PinnedOrder = 0 for collectionIndex := range s.collections[ref.Owner] { @@ -640,7 +704,7 @@ func (s *StarGiftStore) validCollectionGiftIDsLocked(owner domain.Peer, ids []in } valid := false for _, gift := range s.gifts { - if gift.ID == id && gift.Owner == owner && !gift.Converted { + if gift.ID == id && gift.Owner == owner && gift.LifecycleStatus.Live() { valid = true break } @@ -707,6 +771,7 @@ func cloneCollectibleAttribute(in domain.StarGiftCollectibleAttribute) domain.St func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.StarGiftCollectibleRevision { out := in + out.SourceManifestSHA256 = append([]byte(nil), in.SourceManifestSHA256...) clone := func(attributes []domain.StarGiftCollectibleAttribute) []domain.StarGiftCollectibleAttribute { copy := make([]domain.StarGiftCollectibleAttribute, len(attributes)) for i, attribute := range attributes { @@ -747,10 +812,14 @@ func validStarGiftOwner(owner domain.Peer) bool { return owner.ID != 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel) } -func savedStarGiftMatchesRef(g domain.SavedStarGift, ref domain.SavedStarGiftRef) bool { +func (s *StarGiftStore) savedStarGiftMatchesRef(g domain.SavedStarGift, ref domain.SavedStarGiftRef) bool { if g.Owner != ref.Owner { return false } + if ref.Slug != "" { + uniqueID, ok := s.uniqueBySlug[strings.ToLower(strings.TrimSpace(ref.Slug))] + return ok && uniqueID != 0 && g.UniqueGiftID == uniqueID + } switch ref.Owner.Type { case domain.PeerTypeUser: return g.MsgID == ref.MsgID diff --git a/internal/store/memory/star_gift_identity_test.go b/internal/store/memory/star_gift_identity_test.go new file mode 100644 index 00000000..d3203ca2 --- /dev/null +++ b/internal/store/memory/star_gift_identity_test.go @@ -0,0 +1,41 @@ +package memory + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func TestSavedStarGiftIdentityDoesNotAcceptUpgradeMessageID(t *testing.T) { + ctx := context.Background() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 42} + store := NewStarGiftStore() + id, err := store.Create(ctx, domain.SavedStarGift{ + Owner: owner, GiftID: 8001, RevisionID: 9001, MsgID: 115, + UniqueGiftID: 901, UpgradeMsgID: 116, + }) + if err != nil { + t.Fatalf("create saved gift: %v", err) + } + store.uniqueBySlug["official-8001-1"] = 901 + + canonical := domain.SavedStarGiftRef{Owner: owner, MsgID: 115} + if saved, found, err := store.GetByRef(ctx, canonical); err != nil || !found || saved.ID != id { + t.Fatalf("canonical identity: saved=%+v found=%v err=%v", saved, found, err) + } + wrong := domain.SavedStarGiftRef{Owner: owner, MsgID: 116} + if saved, found, err := store.GetByRef(ctx, wrong); err != nil || found { + t.Fatalf("upgrade message id resolved gift: saved=%+v found=%v err=%v", saved, found, err) + } + if _, err := store.ResolveSavedIDs(ctx, owner, []domain.SavedStarGiftRef{wrong}); !errors.Is(err, domain.ErrStarGiftNotFound) { + t.Fatalf("upgrade message id resolve err=%v, want ErrStarGiftNotFound", err) + } + if _, err := store.ResolveSavedIDs(ctx, owner, []domain.SavedStarGiftRef{ + canonical, + {Owner: owner, Slug: "official-8001-1"}, + }); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + t.Fatalf("duplicate official identities err=%v", err) + } +} diff --git a/internal/store/postgres/channel_groupcall.go b/internal/store/postgres/channel_groupcall.go index fc08b6e7..55fb1f20 100644 --- a/internal/store/postgres/channel_groupcall.go +++ b/internal/store/postgres/channel_groupcall.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/jackc/pgx/v5" + "telesrv/internal/domain" ) @@ -53,6 +55,22 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se _ = tx.Rollback(ctx) } }() + if err := s.appendStarGiftAdminLogTx(ctx, tx, channelID, senderUserID, savedID, date, action); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit star gift admin log: %w", err) + } + committed = true + return nil +} + +// appendStarGiftAdminLogTx is the aggregate-local form used when the saved gift, +// inventory/balance mutation and Recent Actions entry must commit together. +func (s *ChannelStore) appendStarGiftAdminLogTx(ctx context.Context, tx pgx.Tx, channelID, senderUserID, savedID int64, date int, action domain.ChannelMessageAction) error { + if channelID == 0 || senderUserID == 0 || savedID <= 0 { + return domain.ErrChannelInvalid + } channel, err := getChannelByID(ctx, tx, channelID) if err != nil { return err @@ -63,29 +81,14 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se } action = channelServiceActionForMessage(channelID, messageID, action) msg := domain.ChannelMessage{ - ChannelID: channelID, - ID: messageID, - SenderUserID: senderUserID, - From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, - Date: date, - Post: channel.Broadcast, - Action: &action, - Pts: channel.Pts, + ChannelID: channelID, ID: messageID, SenderUserID: senderUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, Date: date, + Post: channel.Broadcast, Action: &action, Pts: channel.Pts, } - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: senderUserID, - Date: date, - Type: domain.ChannelAdminLogSendMessage, - Message: &msg, - }); err != nil { - return err - } - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("commit star gift admin log: %w", err) - } - committed = true - return nil + return s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, UserID: senderUserID, Date: date, + Type: domain.ChannelAdminLogSendMessage, Message: &msg, + }) } func (s *ChannelStore) appendServiceMessage(ctx context.Context, label string, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) { diff --git a/internal/store/postgres/star_gift.go b/internal/store/postgres/star_gift.go index f334c676..2e65a3bb 100644 --- a/internal/store/postgres/star_gift.go +++ b/internal/store/postgres/star_gift.go @@ -24,6 +24,16 @@ func NewStarGiftStore(db sqlcgen.DBTX) *StarGiftStore { const starGiftCatalogSelect = ` SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title, + r.limited, r.sold_out, r.birthday, r.require_premium, + r.limited_per_user, r.peer_color_available, r.auction, + c.availability_remains, r.availability_total, c.availability_resale, + c.first_sale_date, c.last_sale_date, c.resell_min_stars, + COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0), + r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round, + r.auction_start_date, r.upgrade_variants, + r.background_center_color IS NOT NULL, + COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0), + COALESCE(r.background_text_color, 0), COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0), d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text, d.thumbs::text @@ -75,6 +85,16 @@ func (s *StarGiftStore) CatalogRevision(ctx context.Context, revisionID int64) ( } gift, err := scanCatalogGift(s.db.QueryRow(ctx, ` SELECT r.gift_id, r.id, r.stars, r.convert_stars, r.title, + r.limited, r.sold_out, r.birthday, r.require_premium, + r.limited_per_user, r.peer_color_available, r.auction, + c.availability_remains, r.availability_total, c.availability_resale, + c.first_sale_date, c.last_sale_date, c.resell_min_stars, + COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0), + r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round, + r.auction_start_date, r.upgrade_variants, + r.background_center_color IS NOT NULL, + COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0), + COALESCE(r.background_text_color, 0), COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0), d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text, d.thumbs::text @@ -95,14 +115,34 @@ WHERE r.id = $1`, revisionID)) func scanCatalogGift(row rowScanner) (domain.StarGift, error) { var gift domain.StarGift var attrsJSON, thumbsJSON string + var releasedByType string + var releasedByID int64 + var hasBackground bool + var background domain.StarGiftBackground if err := row.Scan( &gift.ID, &gift.RevisionID, &gift.Stars, &gift.ConvertStars, &gift.Title, + &gift.Limited, &gift.SoldOut, &gift.Birthday, &gift.RequirePremium, + &gift.LimitedPerUser, &gift.PeerColorAvailable, &gift.Auction, + &gift.AvailabilityRemains, &gift.AvailabilityTotal, &gift.AvailabilityResale, + &gift.FirstSaleDate, &gift.LastSaleDate, &gift.ResellMinStars, + &releasedByType, &releasedByID, &gift.PerUserTotal, &gift.LockedUntilDate, + &gift.AuctionSlug, &gift.GiftsPerRound, &gift.AuctionStartDate, &gift.UpgradeVariants, + &hasBackground, &background.CenterColor, &background.EdgeColor, &background.TextColor, &gift.UpgradeStars, &gift.UpgradeTotal, &gift.UpgradeIssued, &gift.Sticker.ID, &gift.Sticker.AccessHash, &gift.Sticker.FileReference, &gift.Sticker.Date, &gift.Sticker.MimeType, &gift.Sticker.Size, &gift.Sticker.DCID, &attrsJSON, &thumbsJSON, ); err != nil { return domain.StarGift{}, err } + if releasedByType != "" && releasedByID > 0 { + gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID} + } + if hasBackground { + gift.Background = &background + } + if gift.LimitedPerUser { + gift.PerUserRemains = gift.PerUserTotal + } attrs, err := decodeDocumentAttributes(attrsJSON) if err != nil { return domain.StarGift{}, fmt.Errorf("decode star gift document attributes: %w", err) @@ -148,8 +188,12 @@ func (s *StarGiftStore) CreateCatalogRevision(ctx context.Context, write domain. return fmt.Errorf("allocate star gift id: %w", err) } if _, err := tx.Exec(ctx, ` -INSERT INTO star_gift_catalog (gift_id, active_revision_id, enabled, sort_order) -VALUES ($1,$2,$3,$4)`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil { +INSERT INTO star_gift_catalog ( + gift_id, active_revision_id, enabled, sort_order, availability_remains, + availability_resale, resell_min_stars, first_sale_date, last_sale_date +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, giftID, revisionID, write.Enabled, write.SortOrder, + write.AvailabilityRemains, write.AvailabilityResale, write.ResellMinStars, + write.FirstSaleDate, write.LastSaleDate); err != nil { return fmt.Errorf("insert star gift catalog: %w", err) } } else { @@ -180,20 +224,38 @@ WHERE gift_id = $1`, giftID).Scan(&revision); err != nil { INSERT INTO star_gift_catalog_revisions ( id, gift_id, revision, title, stars, convert_stars, document_id, animation_json, animation_sha256, source_name, source_format, - width, height, frame_rate, in_point, out_point, created_by, command_id -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, + width, height, frame_rate, in_point, out_point, created_by, command_id, + official_gift_id, source_manifest_sha256, official_source, + limited, sold_out, birthday, require_premium, limited_per_user, + peer_color_available, auction, availability_total, + released_by_peer_type, released_by_peer_id, per_user_total, locked_until_date, + auction_slug, gifts_per_round, auction_start_date, upgrade_variants, + background_center_color, background_edge_color, background_text_color +) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, + NULLIF($19::bigint,0),$20,$21::jsonb,$22,$23,$24,$25,$26,$27,$28,$29, + $30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40 +)`, revisionID, giftID, revision, write.Title, write.Stars, write.ConvertStars, write.Document.ID, string(write.Animation.JSON), write.Animation.SHA256, write.Animation.SourceName, string(write.Animation.SourceFormat), write.Animation.Width, write.Animation.Height, write.Animation.FrameRate, write.Animation.InPoint, write.Animation.OutPoint, - write.Actor, write.CommandID, + write.Actor, write.CommandID, write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256), nullableOfficialGiftJSON(write.OfficialSourceJSON), + write.Limited, write.SoldOut, write.Birthday, write.RequirePremium, write.LimitedPerUser, + write.PeerColorAvailable, write.Auction, write.AvailabilityTotal, + nullableStarGiftPeerType(write.ReleasedBy), nullableStarGiftPeerID(write.ReleasedBy), write.PerUserTotal, + write.LockedUntilDate, write.AuctionSlug, write.GiftsPerRound, write.AuctionStartDate, + write.UpgradeVariants, nullableBackgroundColor(write.Background, "center"), + nullableBackgroundColor(write.Background, "edge"), nullableBackgroundColor(write.Background, "text"), ); err != nil { return fmt.Errorf("insert star gift revision: %w", err) } if write.GiftID != 0 { if _, err := tx.Exec(ctx, ` UPDATE star_gift_catalog -SET active_revision_id=$2, enabled=$3, sort_order=$4, updated_at=now() -WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil { +SET active_revision_id=$2, enabled=$3, sort_order=$4, availability_remains=$5, + availability_resale=$6, resell_min_stars=$7, first_sale_date=$8, last_sale_date=$9, updated_at=now() +WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder, write.AvailabilityRemains, + write.AvailabilityResale, write.ResellMinStars, write.FirstSaleDate, write.LastSaleDate); err != nil { return fmt.Errorf("activate star gift revision: %w", err) } } @@ -208,6 +270,62 @@ WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != n return entry, nil } +func nullableStarGiftPeerType(peer domain.Peer) any { + if peer.ID <= 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) { + return nil + } + return string(peer.Type) +} + +func nullableStarGiftPeerID(peer domain.Peer) any { + if nullableStarGiftPeerType(peer) == nil { + return nil + } + return peer.ID +} + +func nullableBackgroundColor(background *domain.StarGiftBackground, component string) any { + if background == nil { + return nil + } + switch component { + case "center": + return background.CenterColor + case "edge": + return background.EdgeColor + default: + return background.TextColor + } +} + +func (s *StarGiftStore) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) { + var result domain.StarGiftCatalogBundleResult + err := withTx(ctx, s.db, "create star gift catalog bundle", func(tx pgx.Tx) error { + nested := NewStarGiftStore(tx) + entry, err := nested.CreateCatalogRevision(ctx, write.Catalog) + if err != nil { + return err + } + result.Catalog = entry + if write.Collectible != nil { + collectibleWrite := *write.Collectible + collectibleWrite.GiftID = entry.Gift.ID + revision, err := nested.PublishCollectibleRevision(ctx, collectibleWrite) + if err != nil { + return err + } + result.Collectible = &revision + entry, err = catalogEntryByID(ctx, tx, entry.Gift.ID) + if err != nil { + return err + } + result.Catalog = entry + } + return nil + }) + return result, err +} + func (s *StarGiftStore) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) { tag, err := s.db.Exec(ctx, ` UPDATE star_gift_catalog SET enabled=$2, updated_at=now() @@ -267,6 +385,16 @@ WHERE c.gift_id=$1`, giftID).Scan(&raw) func catalogEntryByID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (domain.StarGiftCatalogEntry, error) { row := db.QueryRow(ctx, ` SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title, + r.limited, r.sold_out, r.birthday, r.require_premium, + r.limited_per_user, r.peer_color_available, r.auction, + c.availability_remains, r.availability_total, c.availability_resale, + c.first_sale_date, c.last_sale_date, c.resell_min_stars, + COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0), + r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round, + r.auction_start_date, r.upgrade_variants, + r.background_center_color IS NOT NULL, + COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0), + COALESCE(r.background_text_color, 0), COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0), d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text, d.thumbs::text, @@ -280,8 +408,20 @@ JOIN documents d ON d.id=r.document_id WHERE c.gift_id=$1`, giftID) var entry domain.StarGiftCatalogEntry var attrsJSON, thumbsJSON, sourceFormat string + var releasedByType string + var releasedByID int64 + var hasBackground bool + var background domain.StarGiftBackground if err := row.Scan( &entry.Gift.ID, &entry.Gift.RevisionID, &entry.Gift.Stars, &entry.Gift.ConvertStars, &entry.Gift.Title, + &entry.Gift.Limited, &entry.Gift.SoldOut, &entry.Gift.Birthday, &entry.Gift.RequirePremium, + &entry.Gift.LimitedPerUser, &entry.Gift.PeerColorAvailable, &entry.Gift.Auction, + &entry.Gift.AvailabilityRemains, &entry.Gift.AvailabilityTotal, &entry.Gift.AvailabilityResale, + &entry.Gift.FirstSaleDate, &entry.Gift.LastSaleDate, &entry.Gift.ResellMinStars, + &releasedByType, &releasedByID, &entry.Gift.PerUserTotal, &entry.Gift.LockedUntilDate, + &entry.Gift.AuctionSlug, &entry.Gift.GiftsPerRound, &entry.Gift.AuctionStartDate, + &entry.Gift.UpgradeVariants, &hasBackground, &background.CenterColor, &background.EdgeColor, + &background.TextColor, &entry.Gift.UpgradeStars, &entry.Gift.UpgradeTotal, &entry.Gift.UpgradeIssued, &entry.Gift.Sticker.ID, &entry.Gift.Sticker.AccessHash, &entry.Gift.Sticker.FileReference, &entry.Gift.Sticker.Date, &entry.Gift.Sticker.MimeType, &entry.Gift.Sticker.Size, &entry.Gift.Sticker.DCID, &attrsJSON, &thumbsJSON, @@ -291,6 +431,15 @@ WHERE c.gift_id=$1`, giftID) ); err != nil { return domain.StarGiftCatalogEntry{}, err } + if releasedByType != "" && releasedByID > 0 { + entry.Gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID} + } + if hasBackground { + entry.Gift.Background = &background + } + if entry.Gift.LimitedPerUser { + entry.Gift.PerUserRemains = entry.Gift.PerUserTotal + } attrs, err := decodeDocumentAttributes(attrsJSON) if err != nil { return domain.StarGiftCatalogEntry{}, err @@ -315,14 +464,14 @@ func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) ( WITH next_id AS ( SELECT nextval(pg_get_serial_sequence('public.peer_star_gifts', 'id'))::bigint AS id ) -INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, message) +INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, prepaid_upgrade_hash, gift_num, message) SELECT next_id.id, $1,$2,$3,$4,$5,$6, CASE WHEN $1 = 'channel' AND $7::bigint = 0 THEN next_id.id ELSE $7::bigint END, - $8,$9,$10,false,$11,$12,$13 + $8,$9,$10,false,$11,$12,$13,$14,$15 FROM next_id RETURNING id`, string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.RevisionID, gift.MsgID, gift.SavedID, gift.Date, - gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.Message).Scan(&id) + gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.PrepaidUpgradeHash, gift.GiftNum, gift.Message).Scan(&id) if err != nil { return 0, fmt.Errorf("create star gift: %w", err) } @@ -347,7 +496,7 @@ func (s *StarGiftStore) ListByOwnerFiltered(ctx context.Context, filter domain.S JOIN star_gift_catalog c ON c.gift_id = p.gift_id LEFT JOIN star_gift_collectible_revisions acr ON acr.id = c.collectible_revision_id AND acr.status = 'published'` - conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "NOT p.converted"} + conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "p.lifecycle_status = 'active'"} args := []any{string(owner.Type), owner.ID} if filter.ExcludeUnsaved { conditions = append(conditions, "NOT p.unsaved") @@ -394,7 +543,9 @@ WHERE ci.saved_gift_id = p.id AND ci.collection_id = $%d limitPlaceholder := len(args) rows, err := s.db.Query(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, - p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, + p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num, + p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at, + p.drop_original_details_stars, p.can_craft_at, p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order, COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id) FROM star_gift_collection_items i @@ -434,47 +585,95 @@ func (s *StarGiftStore) ResolveSavedIDs(ctx context.Context, owner domain.Peer, if len(refs) == 0 { return []int64{}, nil } + type resolveKey struct { + value int64 + slug string + } + keys := make([]resolveKey, 0, len(refs)) values := make([]int64, 0, len(refs)) - seenValues := make(map[int64]struct{}, len(refs)) - column := "msg_id" + slugs := make([]string, 0, len(refs)) + seenKeys := make(map[string]struct{}, len(refs)) for _, ref := range refs { if ref.Owner != owner || !ref.Valid() { return nil, domain.ErrStarGiftNotFound } + if ref.Slug != "" { + slug := strings.ToLower(strings.TrimSpace(ref.Slug)) + key := "slug:" + slug + if _, duplicate := seenKeys[key]; duplicate { + return nil, domain.ErrStarGiftCollectibleInvalid + } + seenKeys[key] = struct{}{} + keys = append(keys, resolveKey{slug: slug}) + slugs = append(slugs, slug) + continue + } value := int64(ref.MsgID) if owner.Type == domain.PeerTypeChannel { - column = "saved_id" value = ref.SavedID } - if _, duplicate := seenValues[value]; duplicate { + key := fmt.Sprintf("id:%d", value) + if _, duplicate := seenKeys[key]; duplicate { return nil, domain.ErrStarGiftCollectibleInvalid } - seenValues[value] = struct{}{} + seenKeys[key] = struct{}{} + keys = append(keys, resolveKey{value: value}) values = append(values, value) } - rows, err := s.db.Query(ctx, `SELECT `+column+`::bigint, id FROM peer_star_gifts -WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND `+column+`::bigint=ANY($3::bigint[])`, string(owner.Type), owner.ID, values) + query := `SELECT p.saved_id::bigint, COALESCE(u.slug, ''), p.id +FROM peer_star_gifts p +LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id +WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active' + AND (p.saved_id::bigint=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))` + if owner.Type == domain.PeerTypeUser { + query = `SELECT p.msg_id::bigint, COALESCE(u.slug, ''), p.id +FROM peer_star_gifts p +LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id +WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active' + AND (p.msg_id::bigint=ANY($3::bigint[]) + OR u.slug=ANY($4::text[]))` + } + rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs) if err != nil { return nil, fmt.Errorf("resolve saved star gifts: %w", err) } defer rows.Close() - resolved := make(map[int64]int64, len(values)) + resolvedValues := make(map[int64]int64, len(values)) + resolvedSlugs := make(map[string]int64, len(slugs)) for rows.Next() { - var value, id int64 - if err := rows.Scan(&value, &id); err != nil { + var primaryValue, id int64 + var slug string + if err := rows.Scan(&primaryValue, &slug, &id); err != nil { return nil, fmt.Errorf("scan resolved saved star gift: %w", err) } - resolved[value] = id + if existing := resolvedValues[primaryValue]; existing != 0 && existing != id { + return nil, domain.ErrStarGiftCollectibleInvalid + } + resolvedValues[primaryValue] = id + if slug != "" { + if existing := resolvedSlugs[slug]; existing != 0 && existing != id { + return nil, domain.ErrStarGiftCollectibleInvalid + } + resolvedSlugs[slug] = id + } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate resolved saved star gifts: %w", err) } - out := make([]int64, 0, len(values)) - for _, value := range values { - id := resolved[value] + out := make([]int64, 0, len(keys)) + seenIDs := make(map[int64]struct{}, len(keys)) + for _, key := range keys { + id := resolvedValues[key.value] + if key.slug != "" { + id = resolvedSlugs[key.slug] + } if id == 0 { return nil, domain.ErrStarGiftNotFound } + if _, duplicate := seenIDs[id]; duplicate { + return nil, domain.ErrStarGiftCollectibleInvalid + } + seenIDs[id] = struct{}{} out = append(out, id) } return out, nil @@ -487,7 +686,9 @@ func (s *StarGiftStore) GetByRef(ctx context.Context, ref domain.SavedStarGiftRe where, args := savedStarGiftRefWhere(ref) row := s.db.QueryRow(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, - p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, + p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num, + p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at, + p.drop_original_details_stars, p.can_craft_at, p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order, COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id) FROM star_gift_collection_items i @@ -510,7 +711,7 @@ func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (in return 0, nil } var n int - if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND NOT converted AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil { + if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND lifecycle_status='active' AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil { return 0, fmt.Errorf("count star gifts: %w", err) } return n, nil @@ -524,7 +725,7 @@ func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGift args = append(args, unsaved) tag, err := s.db.Exec(ctx, ` UPDATE peer_star_gifts SET unsaved = $4 -WHERE `+where+` AND NOT converted`, args...) +WHERE `+where+` AND lifecycle_status='active'`, args...) if err != nil { return false, fmt.Errorf("set star gift unsaved: %w", err) } @@ -543,7 +744,9 @@ func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarG where, args := savedStarGiftRefWhere(ref) row := tx.QueryRow(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, - p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, + p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num, + p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at, + p.drop_original_details_stars, p.can_craft_at, p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order, COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id) FROM star_gift_collection_items i @@ -564,13 +767,14 @@ WHERE `+where+` FOR UPDATE`, args...) if g.UniqueGiftID != 0 { return domain.ErrStarGiftAlreadyUpgraded } - if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil { + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, lifecycle_status='converted', unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil { return fmt.Errorf("mark star gift converted: %w", err) } if err := removeSavedGiftFromCollections(ctx, tx, g.Owner, g.ID); err != nil { return err } g.Converted = true + g.LifecycleStatus = domain.StarGiftLifecycleConverted g.Unsaved = true g.PinnedOrder = 0 g.CollectionIDs = nil @@ -587,7 +791,9 @@ func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) { var g domain.SavedStarGift var ownerType string if err := row.Scan(&g.ID, &ownerType, &g.Owner.ID, &g.FromUserID, &g.GiftID, &g.RevisionID, &g.MsgID, &g.SavedID, &g.Date, - &g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.Message, &g.UniqueGiftID, + &g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.PrepaidUpgradeHash, &g.GiftNum, + &g.LifecycleStatus, &g.TransferStars, &g.CanExportAt, &g.CanTransferAt, &g.CanResellAt, + &g.DropOriginalDetailsStars, &g.CanCraftAt, &g.Message, &g.UniqueGiftID, &g.UpgradeMsgID, &g.PinnedOrder, &g.CollectionIDs); err != nil { return domain.SavedStarGift{}, err } @@ -597,6 +803,10 @@ func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) { func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) { args := []any{string(ref.Owner.Type), ref.Owner.ID} + if ref.Slug != "" { + args = append(args, strings.ToLower(strings.TrimSpace(ref.Slug))) + return "owner_peer_type = $1 AND owner_peer_id = $2 AND unique_gift_id = (SELECT id FROM unique_star_gifts WHERE slug = $3)", args + } switch ref.Owner.Type { case domain.PeerTypeChannel: args = append(args, ref.SavedID) diff --git a/internal/store/postgres/star_gift_collectibles.go b/internal/store/postgres/star_gift_collectibles.go index 2d2a2be6..4e2260b6 100644 --- a/internal/store/postgres/star_gift_collectibles.go +++ b/internal/store/postgres/star_gift_collectibles.go @@ -14,6 +14,34 @@ import ( "telesrv/internal/store/postgres/sqlcgen" ) +func nullablePermille(attribute domain.StarGiftCollectibleAttribute) any { + if attribute.RarityKind != domain.StarGiftRarityPermille { + return nil + } + return attribute.RarityPermille +} + +func nullableSHA256(value []byte) any { + if len(value) == 0 { + return nil + } + return value +} + +func nullablePositiveInt64(value int64) any { + if value <= 0 { + return nil + } + return value +} + +func nullableOfficialGiftJSON(value []byte) any { + if len(value) == 0 { + return nil + } + return string(value) +} + func (s *StarGiftStore) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) { write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix)) write.Actor = strings.TrimSpace(write.Actor) @@ -38,13 +66,15 @@ SELECT COALESCE(MAX(revision), 0) + 1 FROM star_gift_collectible_revisions WHERE var revisionID int64 if err := tx.QueryRow(ctx, ` INSERT INTO star_gift_collectible_revisions - (gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id) -VALUES ($1,$2,$3,$4,$5,'draft',$6,$7) -RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID).Scan(&revisionID); err != nil { + (gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id, + official_gift_id, source_manifest_sha256) +VALUES ($1,$2,$3,$4,$5,'draft',$6,$7,NULLIF($8::bigint,0),$9) +RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID, + write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256)).Scan(&revisionID); err != nil { return fmt.Errorf("insert collectible revision: %w", err) } media := NewMediaStore(tx) - insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute) error { + insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute, models bool) error { for _, attribute := range attributes { if err := media.PutDocument(ctx, *attribute.Document); err != nil { return fmt.Errorf("put collectible %s document: %w", attribute.Kind, err) @@ -53,35 +83,53 @@ RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, wr return fmt.Errorf("put collectible %s blob: %w", attribute.Kind, err) } animation := attribute.Animation - query := fmt.Sprintf(` + var query string + if models { + query = fmt.Sprintf(` INSERT INTO %s (collectible_revision_id, name, document_id, animation_json, animation_sha256, source_name, source_format, width, height, frame_rate, in_point, out_point, - rarity_permille, sort_order) -VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, table) - if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID, - string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat), - animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint, - attribute.RarityPermille, attribute.SortOrder); err != nil { - return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err) + rarity_kind, rarity_permille, crafted, official_document_id, sort_order) +VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, table) + if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID, + string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat), + animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint, + string(attribute.RarityKind), nullablePermille(attribute), attribute.Crafted, + nullablePositiveInt64(attribute.OfficialDocumentID), attribute.SortOrder); err != nil { + return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err) + } + } else { + query = fmt.Sprintf(` +INSERT INTO %s + (collectible_revision_id, name, document_id, animation_json, animation_sha256, + source_name, source_format, width, height, frame_rate, in_point, out_point, + rarity_kind, rarity_permille, official_document_id, sort_order) +VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, table) + if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID, + string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat), + animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint, + string(attribute.RarityKind), nullablePermille(attribute), nullablePositiveInt64(attribute.OfficialDocumentID), + attribute.SortOrder); err != nil { + return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err) + } } } return nil } - if err := insertAnimated("star_gift_collectible_models", write.Models); err != nil { + if err := insertAnimated("star_gift_collectible_models", write.Models, true); err != nil { return err } - if err := insertAnimated("star_gift_collectible_patterns", write.Patterns); err != nil { + if err := insertAnimated("star_gift_collectible_patterns", write.Patterns, false); err != nil { return err } for _, attribute := range write.Backdrops { if _, err := tx.Exec(ctx, ` INSERT INTO star_gift_collectible_backdrops (collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color, - text_color, rarity_permille, sort_order) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID, + text_color, rarity_kind, rarity_permille, sort_order) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID, attribute.CenterColor, attribute.EdgeColor, attribute.PatternColor, attribute.TextColor, - attribute.RarityPermille, attribute.SortOrder); err != nil { + string(attribute.RarityKind), nullablePermille(attribute), attribute.SortOrder); err != nil { return fmt.Errorf("insert collectible backdrop: %w", err) } } @@ -152,10 +200,11 @@ func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID in var publishedAt pgtype.Timestamptz if err := db.QueryRow(ctx, ` SELECT id, gift_id, revision, upgrade_stars, supply_total, issued, slug_prefix, status, - created_by, created_at, published_at + created_by, created_at, published_at, COALESCE(official_gift_id,0), source_manifest_sha256 FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan( &revision.ID, &revision.GiftID, &revision.Revision, &revision.UpgradeStars, &revision.SupplyTotal, &revision.Issued, &revision.SlugPrefix, &status, &revision.CreatedBy, &revision.CreatedAt, &publishedAt, + &revision.OfficialGiftID, &revision.SourceManifestSHA256, ); err != nil { return domain.StarGiftCollectibleRevision{}, fmt.Errorf("get collectible revision: %w", err) } @@ -184,13 +233,20 @@ func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, rev return nil, domain.ErrStarGiftCollectibleInvalid } rows, err := db.Query(ctx, fmt.Sprintf(` -SELECT a.id, a.collectible_revision_id, a.name, a.rarity_permille, a.sort_order, +SELECT a.id, a.collectible_revision_id, a.name, a.rarity_kind, COALESCE(a.rarity_permille,0), + %s, COALESCE(a.official_document_id,0), a.sort_order, a.animation_json::text, a.animation_sha256, a.source_name, a.source_format, a.width, a.height, a.frame_rate, a.in_point, a.out_point, d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text, d.thumbs::text FROM %s a JOIN documents d ON d.id=a.document_id -WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisionID) +WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, + func() string { + if kind == domain.StarGiftCollectibleModel { + return "a.crafted" + } + return "false" + }(), table), revisionID) if err != nil { return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err) } @@ -199,7 +255,8 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisio for rows.Next() { attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Document: &domain.Document{}, Animation: &domain.StarGiftAnimation{}} var attrsJSON, thumbsJSON, sourceFormat string - if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityPermille, &attribute.SortOrder, + if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityKind, + &attribute.RarityPermille, &attribute.Crafted, &attribute.OfficialDocumentID, &attribute.SortOrder, &attribute.Animation.JSON, &attribute.Animation.SHA256, &attribute.Animation.SourceName, &sourceFormat, &attribute.Animation.Width, &attribute.Animation.Height, &attribute.Animation.FrameRate, &attribute.Animation.InPoint, &attribute.Animation.OutPoint, &attribute.Document.ID, &attribute.Document.AccessHash, &attribute.Document.FileReference, &attribute.Document.Date, @@ -221,7 +278,7 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisio func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) { rows, err := db.Query(ctx, ` SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color, - text_color, rarity_permille, sort_order + text_color, rarity_kind, COALESCE(rarity_permille,0), sort_order FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID) if err != nil { return nil, fmt.Errorf("list collectible backdrops: %w", err) @@ -232,7 +289,7 @@ FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY s attribute := domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop} if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.BackdropID, &attribute.CenterColor, &attribute.EdgeColor, &attribute.PatternColor, &attribute.TextColor, - &attribute.RarityPermille, &attribute.SortOrder); err != nil { + &attribute.RarityKind, &attribute.RarityPermille, &attribute.SortOrder); err != nil { return nil, err } out = append(out, attribute) @@ -307,14 +364,24 @@ func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string, func uniqueStarGiftQuery(predicate string) string { return fmt.Sprintf(` SELECT u.id, u.gift_id, u.collectible_revision_id, u.source_saved_gift_id, u.title, u.slug, u.num, - u.owner_peer_type, u.owner_peer_id, u.keep_original_details, u.created_at, - r.issued, r.supply_total, sg.from_user_id, sg.owner_peer_type, sg.owner_peer_id, + COALESCE(u.owner_peer_type,''), COALESCE(u.owner_peer_id,0), u.keep_original_details, u.created_at, + u.require_premium, u.resale_ton_only, u.theme_available, u.burned, u.crafted, + u.owner_name, u.owner_address, u.gift_address, + COALESCE(l.currency,''), COALESCE(l.amount,0), COALESCE(l.version,0), + COALESCE(u.released_by_peer_type,''), COALESCE(u.released_by_peer_id,0), + u.value_amount, u.value_currency, u.value_usd_amount, + COALESCE(u.theme_peer_type,''), COALESCE(u.theme_peer_id,0), + COALESCE(u.host_peer_type,''), COALESCE(u.host_peer_id,0), + u.offer_min_stars, u.craft_chance_permille, u.last_sale_date, + u.last_sale_currency, u.last_sale_amount, + r.issued, r.supply_total, sg.from_user_id, u.original_owner_peer_type, u.original_owner_peer_id, sg.gift_date, sg.message, sg.name_hidden, - m.id, m.name, m.rarity_permille, md.id, md.access_hash, md.file_reference, md.date, + m.id, m.name, m.rarity_kind, COALESCE(m.rarity_permille,0), m.crafted, md.id, md.access_hash, md.file_reference, md.date, md.mime_type, md.size, md.dc_id, md.attributes::text, md.thumbs::text, - p.id, p.name, p.rarity_permille, pd.id, pd.access_hash, pd.file_reference, pd.date, + p.id, p.name, p.rarity_kind, COALESCE(p.rarity_permille,0), pd.id, pd.access_hash, pd.file_reference, pd.date, pd.mime_type, pd.size, pd.dc_id, pd.attributes::text, pd.thumbs::text, - b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color, b.rarity_permille + b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color, + b.rarity_kind, COALESCE(b.rarity_permille,0) FROM unique_star_gifts u JOIN star_gift_collectible_revisions r ON r.id=u.collectible_revision_id JOIN star_gift_collectible_models m ON m.id=u.model_attribute_id @@ -323,12 +390,14 @@ JOIN star_gift_collectible_patterns p ON p.id=u.pattern_attribute_id JOIN documents pd ON pd.id=p.document_id JOIN star_gift_collectible_backdrops b ON b.id=u.backdrop_attribute_id JOIN peer_star_gifts sg ON sg.id=u.source_saved_gift_id +LEFT JOIN star_gift_listings l ON l.unique_gift_id=u.id WHERE %s`, predicate) } func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) { var unique domain.UniqueStarGift - var ownerType, originalOwnerType string + var ownerType, originalOwnerType, listingCurrency, releasedByType, themePeerType, hostPeerType, lastSaleCurrency string + var listingAmount, lastSaleAmount int64 unique.Model.Kind = domain.StarGiftCollectibleModel unique.Pattern.Kind = domain.StarGiftCollectiblePattern unique.Backdrop.Kind = domain.StarGiftCollectibleBackdrop @@ -337,23 +406,40 @@ func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) { var modelAttrs, modelThumbs, patternAttrs, patternThumbs string if err := row.Scan(&unique.ID, &unique.GiftID, &unique.CollectibleRevisionID, &unique.SourceSavedGiftID, &unique.Title, &unique.Slug, &unique.Num, &ownerType, &unique.Owner.ID, &unique.KeepOriginalDetails, - &unique.CreatedAt, &unique.AvailabilityIssued, &unique.AvailabilityTotal, + &unique.CreatedAt, &unique.RequirePremium, &unique.ResaleTonOnly, &unique.ThemeAvailable, + &unique.Burned, &unique.Crafted, &unique.OwnerName, &unique.OwnerAddress, &unique.GiftAddress, + &listingCurrency, &listingAmount, &unique.ResellVersion, &releasedByType, &unique.ReleasedBy.ID, + &unique.ValueAmount, &unique.ValueCurrency, &unique.ValueUSD, + &themePeerType, &unique.ThemePeer.ID, &hostPeerType, &unique.Host.ID, + &unique.OfferMinStars, &unique.CraftChancePermille, &unique.LastSaleDate, + &lastSaleCurrency, &lastSaleAmount, + &unique.AvailabilityIssued, &unique.AvailabilityTotal, &unique.OriginalFromUserID, &originalOwnerType, &unique.OriginalOwner.ID, &unique.OriginalDate, &unique.OriginalMessage, &unique.OriginalNameHidden, - &unique.Model.ID, &unique.Model.Name, &unique.Model.RarityPermille, + &unique.Model.ID, &unique.Model.Name, &unique.Model.RarityKind, &unique.Model.RarityPermille, &unique.Model.Crafted, &unique.Model.Document.ID, &unique.Model.Document.AccessHash, &unique.Model.Document.FileReference, &unique.Model.Document.Date, &unique.Model.Document.MimeType, &unique.Model.Document.Size, &unique.Model.Document.DCID, &modelAttrs, &modelThumbs, - &unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityPermille, + &unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityKind, &unique.Pattern.RarityPermille, &unique.Pattern.Document.ID, &unique.Pattern.Document.AccessHash, &unique.Pattern.Document.FileReference, &unique.Pattern.Document.Date, &unique.Pattern.Document.MimeType, &unique.Pattern.Document.Size, &unique.Pattern.Document.DCID, &patternAttrs, &patternThumbs, &unique.Backdrop.ID, &unique.Backdrop.Name, &unique.Backdrop.BackdropID, &unique.Backdrop.CenterColor, - &unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor, &unique.Backdrop.RarityPermille); err != nil { + &unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor, + &unique.Backdrop.RarityKind, &unique.Backdrop.RarityPermille); err != nil { return domain.UniqueStarGift{}, fmt.Errorf("get unique star gift: %w", err) } unique.Owner.Type = domain.PeerType(ownerType) unique.OriginalOwner.Type = domain.PeerType(originalOwnerType) + unique.ReleasedBy.Type = domain.PeerType(releasedByType) + unique.ThemePeer.Type = domain.PeerType(themePeerType) + unique.Host.Type = domain.PeerType(hostPeerType) + if listingCurrency != "" && listingAmount > 0 { + unique.ResellAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(listingCurrency), Amount: listingAmount} + } + if lastSaleCurrency != "" && unique.LastSaleDate > 0 { + unique.LastSaleAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(lastSaleCurrency), Amount: lastSaleAmount} + } unique.Model.CollectibleRevisionID = unique.CollectibleRevisionID unique.Pattern.CollectibleRevisionID = unique.CollectibleRevisionID unique.Backdrop.CollectibleRevisionID = unique.CollectibleRevisionID @@ -603,7 +689,7 @@ func validatePostgresCollectionGiftIDs(ctx context.Context, db sqlcgen.DBTX, own } rows, err := db.Query(ctx, ` SELECT id FROM peer_star_gifts -WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND id=ANY($3::bigint[]) +WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND lifecycle_status='active' AND id=ANY($3::bigint[]) FOR UPDATE`, string(owner.Type), owner.ID, ids) if err != nil { return nil, err diff --git a/internal/store/postgres/star_gift_collectibles_integration_test.go b/internal/store/postgres/star_gift_collectibles_integration_test.go index 72b242b9..f5c74418 100644 --- a/internal/store/postgres/star_gift_collectibles_integration_test.go +++ b/internal/store/postgres/star_gift_collectibles_integration_test.go @@ -34,26 +34,35 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { poolRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "comet-" + suffix, Models: []domain.StarGiftCollectibleAttribute{{ - Kind: domain.StarGiftCollectibleModel, Name: "Aurora", RarityPermille: 1000, + Kind: domain.StarGiftCollectibleModel, Name: "Aurora", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 922, Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs"), + OfficialDocumentID: 5100000000000000001, + }, { + Kind: domain.StarGiftCollectibleModel, Name: "Crafted Aurora", RarityKind: domain.StarGiftRarityLegendary, Crafted: true, + Document: collectibleTestDocumentPtr(baseDocumentID+3, "crafted-model.tgs"), + Blob: collectibleTestBlobPtr(baseDocumentID+3, "crafted-model"), Animation: collectibleTestAnimationPtr("crafted-model.tgs"), + OfficialDocumentID: 5100000000000000003, }}, Patterns: []domain.StarGiftCollectibleAttribute{{ - Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityPermille: 1000, + Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 989, Document: collectibleTestDocumentPtr(baseDocumentID+2, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs"), }}, Backdrops: []domain.StarGiftCollectibleAttribute{{ Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, - RarityPermille: 1000, + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 999, }}, Actor: "integration", CommandID: "collectibles-" + suffix, + OfficialGiftID: 5170145012310081615, SourceManifestSHA256: make([]byte, 32), }) if err != nil { t.Fatalf("publish collectible pool: %v", err) } - if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 1 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 { + if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 2 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 || + !poolRevision.Models[1].Crafted || poolRevision.Models[1].RarityKind != domain.StarGiftRarityLegendary || + poolRevision.Models[1].RarityPermille != 0 || poolRevision.Models[0].OfficialDocumentID != 5100000000000000001 { t.Fatalf("published pool = %+v", poolRevision) } availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1}) @@ -74,21 +83,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { t.Fatalf("issued after rejected manual update = %d err %v, want 0", guardedIssued, err) } - savedID, err := gifts.Create(ctx, domain.SavedStarGift{ + messages := NewMessageStore(pool) + saved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{ Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, - MsgID: 700001, Date: 1700001000, ConvertStars: 25, Message: "original", + Date: 1700001000, ConvertStars: 25, Message: "original", }) - if err != nil { - t.Fatalf("create saved gift: %v", err) - } + savedID := saved.ID stars := NewStarsStore(pool) if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, 1700001001); err != nil { t.Fatalf("grant upgrade stars: %v", err) } - messages := NewMessageStore(pool) upgrades := NewStarGiftUpgradeStore(pool, messages) req := domain.StarGiftUpgradeRequest{ - UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700001}, + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: saved.MsgID}, KeepOriginalDetails: true, ChargeStars: 100, FormID: 991, CommandKey: "paid-" + suffix, Date: 1700001002, } @@ -108,6 +115,31 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { ownerMessage.Media.ServiceAction.StarGiftUnique == nil || ownerMessage.Media.ServiceAction.StarGiftUnique.Gift.ID != upgraded.Unique.ID { t.Fatalf("owner upgrade service message = %+v", ownerMessage) } + uniqueAction := ownerMessage.Media.ServiceAction.StarGiftUnique + if uniqueAction.SavedID != int64(saved.MsgID) { + t.Fatalf("unique action saved_id = %d, want stable source msg id %d", uniqueAction.SavedID, saved.MsgID) + } + ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID) + if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil || + ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil || + ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != ownerMessage.ID || + ownerSourceEdit.Message.Media.ServiceAction.StarGift.CanUpgrade { + t.Fatalf("owner source gift was not durably marked upgraded: %+v", ownerSourceEdit) + } + senderSourceEdit := upgradedSourceEditForUser(upgraded, sender.ID) + if senderSourceEdit.Message.Media == nil || senderSourceEdit.Message.Media.ServiceAction == nil || + senderSourceEdit.Message.Media.ServiceAction.StarGift == nil || + senderSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Send.SenderMessage.ID { + t.Fatalf("sender source gift has wrong box-local upgrade link: %+v", senderSourceEdit) + } + difference, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, ownerMessage.Pts-1, 4) + if err != nil || len(difference) < 2 || difference[0].Type != domain.UpdateEventNewMessage || + difference[0].Message.ID != ownerMessage.ID || difference[1].Type != domain.UpdateEventEditMessage || + difference[1].Message.ID != saved.MsgID || difference[1].Message.Media == nil || + difference[1].Message.Media.ServiceAction == nil || difference[1].Message.Media.ServiceAction.StarGift == nil || + difference[1].Message.Media.ServiceAction.StarGift.UpgradeMsgID != ownerMessage.ID { + t.Fatalf("owner upgrade difference = %+v err %v", difference, err) + } var ( issued, uniqueCount, commandCount int @@ -128,12 +160,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { if issued != 1 || uniqueCount != 1 || commandCount != 1 || reason != string(domain.StarsReasonGiftUpgrade) { t.Fatalf("durable aggregate issued=%d unique=%d command=%d reason=%q", issued, uniqueCount, commandCount, reason) } + receipt, found, err := upgrades.StarGiftUpgradeReceipt(ctx, owner.ID, req.CommandKey) + if err != nil || !found || receipt.SourceSavedGiftID != savedID || receipt.UniqueGiftID != upgraded.Unique.ID || + receipt.FormID != req.FormID || receipt.ChargeStars != req.ChargeStars || receipt.RequirePrepaid || + !receipt.KeepOriginalDetails || receipt.BalanceAfter != 900 || receipt.SourceEditPts != ownerSourceEdit.Event.Pts { + t.Fatalf("upgrade receipt = %+v found=%v err=%v", receipt, found, err) + } replayed, err := upgrades.UpgradeStarGift(ctx, req) if err != nil { t.Fatalf("replay upgrade: %v", err) } - if !replayed.Duplicate || replayed.Unique.ID != upgraded.Unique.ID || replayed.Balance.Balance != 900 { + if !replayed.Duplicate || replayed.Unique.ID != upgraded.Unique.ID || replayed.Balance.Balance != 900 || + upgradedSourceEditForUser(replayed, owner.ID).Event.Pts != ownerSourceEdit.Event.Pts { t.Fatalf("replayed upgrade = %+v", replayed) } conflictingReplay := req @@ -152,17 +191,15 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { t.Fatalf("balance after retries = %+v err %v", bal, err) } - prepaidSavedID, err := gifts.Create(ctx, domain.SavedStarGift{ + prepaidSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{ Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, // A later pool revision may raise the current price; the historical paid // amount remains an entitlement instead of being compared to that price. - MsgID: 700002, Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50, + Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50, }) - if err != nil { - t.Fatalf("create prepaid saved gift: %v", err) - } + prepaidSavedID := prepaidSaved.ID prepaid, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ - UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700002}, + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: prepaidSaved.MsgID}, RequirePrepaid: true, CommandKey: "prepaid-" + suffix, Date: 1700001005, }) if err != nil { @@ -174,26 +211,24 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { t.Fatalf("prepaid upgrade = %+v", prepaid) } - insufficientSavedID, err := gifts.Create(ctx, domain.SavedStarGift{ + insufficientSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{ Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, - MsgID: 700003, Date: 1700001006, ConvertStars: 25, + Date: 1700001006, ConvertStars: 25, }) - if err != nil { - t.Fatalf("create insufficient saved gift: %v", err) - } + insufficientSavedID := insufficientSaved.ID if _, err := stars.Debit(ctx, owner.ID, 850, domain.StarsReasonReaction, domain.Peer{Type: domain.PeerTypeChannel, ID: 777001}, 1700001007, "paid reaction", ""); err != nil { t.Fatalf("seed isolated paid reaction debit: %v", err) } if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ - UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003}, - ChargeStars: 100, CommandKey: "insufficient-" + suffix, Date: 1700001008, + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID}, + ChargeStars: 100, FormID: 994, CommandKey: "insufficient-" + suffix, Date: 1700001008, }); !errors.Is(err, domain.ErrStarsInsufficient) { t.Fatalf("insufficient upgrade err = %v", err) } - insufficientSaved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003}) - if err != nil || !found || insufficientSaved.ID != insufficientSavedID || insufficientSaved.UniqueGiftID != 0 { - t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientSaved, found, err) + insufficientAfter, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID}) + if err != nil || !found || insufficientAfter.ID != insufficientSavedID || insufficientAfter.UniqueGiftID != 0 { + t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientAfter, found, err) } if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&issued); err != nil || issued != 2 { t.Fatalf("issued after rejected upgrade = %d err %v, want 2", issued, err) @@ -220,12 +255,10 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { concurrentOwner := createTestUser(t, ctx, users, "+1778"+suffix+"43", "ConcurrentOwner", "") concurrentPeer := domain.Peer{Type: domain.PeerTypeUser, ID: concurrentOwner.ID} - if _, err := gifts.Create(ctx, domain.SavedStarGift{ + concurrentSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{ Owner: concurrentPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, - MsgID: 700004, Date: 1700001010, ConvertStars: 25, - }); err != nil { - t.Fatalf("create concurrent upgrade target: %v", err) - } + Date: 1700001010, ConvertStars: 25, + }) if _, _, err := stars.EnsureGrant(ctx, concurrentOwner.ID, 150, 1700001011); err != nil { t.Fatalf("grant concurrent balance: %v", err) } @@ -238,7 +271,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { go func() { <-start _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ - UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: 700004}, + UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: concurrentSaved.MsgID}, ChargeStars: 100, FormID: 993, CommandKey: "concurrent-upgrade-" + suffix, Date: 1700001012, }) results <- concurrentDebitResult{kind: "gift_upgrade", err: err} @@ -302,19 +335,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { soldOutRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ GiftID: soldOutEntry.Gift.ID, UpgradeStars: 10, SupplyTotal: 1, SlugPrefix: "nova-" + suffix, Models: []domain.StarGiftCollectibleAttribute{{ - Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityPermille: 1000, + Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs"), }}, Patterns: []domain.StarGiftCollectibleAttribute{{ - Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityPermille: 1000, + Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, Document: collectibleTestDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs"), }}, Backdrops: []domain.StarGiftCollectibleAttribute{{ Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2, CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff, - RarityPermille: 1000, + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, }}, Actor: "integration", CommandID: "soldout-pool-" + suffix, }) @@ -323,27 +356,26 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { } soldOutOwner := createTestUser(t, ctx, users, "+1778"+suffix+"44", "SoldOutOwner", "") soldOutPeer := domain.Peer{Type: domain.PeerTypeUser, ID: soldOutOwner.ID} - for index, msgID := range []int{700010, 700011} { - if _, err := gifts.Create(ctx, domain.SavedStarGift{ + soldOutSaved := make([]domain.SavedStarGift, 0, 2) + for index := range 2 { + soldOutSaved = append(soldOutSaved, createCollectibleSavedGift(t, ctx, messages, gifts, soldOutEntry.Gift, domain.SavedStarGift{ Owner: soldOutPeer, FromUserID: sender.ID, GiftID: soldOutEntry.Gift.ID, RevisionID: soldOutEntry.Gift.RevisionID, - MsgID: msgID, Date: 1700001020 + index, ConvertStars: 10, - }); err != nil { - t.Fatalf("create sold-out target %d: %v", msgID, err) - } + Date: 1700001020 + index, ConvertStars: 10, + })) } if _, _, err := stars.EnsureGrant(ctx, soldOutOwner.ID, 100, 1700001022); err != nil { t.Fatalf("grant sold-out owner balance: %v", err) } if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ - UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700010}, - ChargeStars: 10, CommandKey: "soldout-first-" + suffix, Date: 1700001023, + UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[0].MsgID}, + ChargeStars: 10, FormID: 995, CommandKey: "soldout-first-" + suffix, Date: 1700001023, }); err != nil { t.Fatalf("fill collectible supply: %v", err) } balanceBeforeSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID) if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ - UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700011}, - ChargeStars: 10, CommandKey: "soldout-second-" + suffix, Date: 1700001024, + UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[1].MsgID}, + ChargeStars: 10, FormID: 996, CommandKey: "soldout-second-" + suffix, Date: 1700001024, }); !errors.Is(err, domain.ErrStarGiftCollectibleSoldOut) { t.Fatalf("sold-out upgrade err = %v", err) } @@ -357,7 +389,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { if err != nil { t.Fatalf("create ordinary collection: %v", err) } - converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003}) + converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID}) if err != nil || !converted.Converted || converted.PinnedOrder != 0 || len(converted.CollectionIDs) != 0 { t.Fatalf("convert collection member = %+v err %v", converted, err) } @@ -386,6 +418,106 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { } } +func TestStarGiftUpgradeWithoutCraftedModelDoesNotAdvertiseCraft(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + now := int(time.Now().Unix()) + users := NewUserStore(pool) + sender := createTestUser(t, ctx, users, "+1779"+suffix+"51", "NoCraftSender", "") + owner := createTestUser(t, ctx, users, "+1779"+suffix+"52", "NoCraftOwner", "") + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + + gifts := NewStarGiftStore(pool) + baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000 + entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ + Title: "No Craft " + suffix, Stars: 50, ConvertStars: 25, Enabled: true, + Document: collectibleTestDocument(baseDocumentID, "no-craft-gift.tgs"), + Blob: collectibleTestBlob(baseDocumentID, "no-craft-gift"), Animation: collectibleTestAnimation("no-craft-gift.tgs"), + Actor: "integration", CommandID: "no-craft-catalog-" + suffix, + }) + if err != nil { + t.Fatalf("create no-craft catalog gift: %v", err) + } + revision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ + GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "no-craft-" + suffix, + Models: []domain.StarGiftCollectibleAttribute{{ + Kind: domain.StarGiftCollectibleModel, Name: "Ordinary", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestDocumentPtr(baseDocumentID+1, "no-craft-model.tgs"), + Blob: collectibleTestBlobPtr(baseDocumentID+1, "no-craft-model"), Animation: collectibleTestAnimationPtr("no-craft-model.tgs"), + }}, + Patterns: []domain.StarGiftCollectibleAttribute{{ + Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestDocumentPtr(baseDocumentID+2, "no-craft-pattern.tgs"), + Blob: collectibleTestBlobPtr(baseDocumentID+2, "no-craft-pattern"), Animation: collectibleTestAnimationPtr("no-craft-pattern.tgs"), + }}, + Backdrops: []domain.StarGiftCollectibleAttribute{{ + Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1, + CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + }}, + Actor: "integration", CommandID: "no-craft-pool-" + suffix, + }) + if err != nil { + t.Fatalf("publish no-craft pool: %v", err) + } + if len(revision.Models) != 1 || revision.Models[0].Crafted { + t.Fatalf("no-craft pool models = %+v", revision.Models) + } + + messages := NewMessageStore(pool) + saved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{ + Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, + Date: now, ConvertStars: 25, + }) + stars := NewStarsStore(pool) + if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, now); err != nil { + t.Fatalf("grant no-craft upgrade stars: %v", err) + } + upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{ + TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 750, + })) + upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: saved.MsgID}, + ChargeStars: 100, FormID: 551, CommandKey: "no-craft-upgrade-" + suffix, Date: now + 1, + }) + if err != nil { + t.Fatalf("upgrade no-craft gift: %v", err) + } + uniqueAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique + if upgraded.Unique.CraftChancePermille != 0 || upgraded.Saved.CanCraftAt != 0 || + uniqueAction == nil || uniqueAction.Gift.CraftChancePermille != 0 || uniqueAction.CanCraftAt != 0 { + t.Fatalf("no-craft capability leaked: saved=%+v unique=%+v action=%+v", upgraded.Saved, upgraded.Unique, uniqueAction) + } + + lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000) + page, err := lifecycle.ListCraftStarGifts(ctx, owner.ID, entry.Gift.ID, "", 10) + if err != nil || page.Count != 0 || len(page.Gifts) != 0 { + t.Fatalf("no-craft candidate page = %+v err %v", page, err) + } + if _, err := lifecycle.CraftStarGift(ctx, domain.StarGiftCraftRequest{ + UserID: owner.ID, Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: saved.MsgID}}, + CommandKey: "no-craft-attempt-" + suffix, Date: now + 2, + }); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) { + t.Fatalf("no-craft attempt err = %v", err) + } + var lifecycleStatus string + var burned bool + var commandCount int + if err := pool.QueryRow(ctx, `SELECT p.lifecycle_status,u.burned +FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE p.id=$1`, upgraded.Saved.ID). + Scan(&lifecycleStatus, &burned); err != nil { + t.Fatalf("load no-craft aggregate: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`, + owner.ID, "no-craft-attempt-"+suffix).Scan(&commandCount); err != nil { + t.Fatalf("count no-craft commands: %v", err) + } + if lifecycleStatus != "active" || burned || commandCount != 0 { + t.Fatalf("no-craft attempt mutated aggregate: status=%q burned=%t commands=%d", lifecycleStatus, burned, commandCount) + } +} + func collectibleTestAnimation(name string) domain.StarGiftAnimation { return domain.StarGiftAnimation{ SourceName: name, SourceFormat: domain.StarGiftAnimationTGS, @@ -427,3 +559,53 @@ func collectibleTestBlobPtr(id int64, suffix string) *domain.FileBlob { blob := collectibleTestBlob(id, suffix) return &blob } + +// createCollectibleSavedGift seeds the same valid source-message + saved-gift +// invariant as the purchase aggregate. Tests must not invent a peer_star_gifts +// msg_id that has no durable message box behind it. +func createCollectibleSavedGift( + t *testing.T, + ctx context.Context, + messages *MessageStore, + gifts *StarGiftStore, + gift domain.StarGift, + saved domain.SavedStarGift, +) domain.SavedStarGift { + t.Helper() + sticker := gift.Sticker + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: saved.FromUserID, + RecipientUserID: saved.Owner.ID, + RandomID: (time.Now().UnixNano() & 0x7fffffffffffffff) ^ saved.Owner.ID ^ int64(saved.Date), + Date: saved.Date, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, + StarGift: &domain.MessageStarGiftAction{ + GiftID: gift.ID, Stars: gift.Stars, ConvertStars: saved.ConvertStars, + Title: gift.Title, Sticker: &sticker, Message: saved.Message, + FromUserID: saved.FromUserID, PeerUserID: saved.Owner.ID, Saved: true, + CanUpgrade: gift.UpgradeStars > 0, PrepaidUpgrade: saved.PrepaidUpgradeStars > 0, + UpgradePriceStars: gift.UpgradeStars, UpgradeStars: saved.PrepaidUpgradeStars, + }, + }}, + }) + if err != nil { + t.Fatalf("create collectible source message: %v", err) + } + saved.MsgID = sent.RecipientMessage.ID + id, err := gifts.Create(ctx, saved) + if err != nil { + t.Fatalf("create saved gift: %v", err) + } + saved.ID = id + return saved +} + +func upgradedSourceEditForUser(result domain.StarGiftUpgradeResult, userID int64) domain.EditedMessageForUser { + for _, edit := range result.SourceEdits { + if edit.UserID == userID { + return edit + } + } + return domain.EditedMessageForUser{UserID: userID} +} diff --git a/internal/store/postgres/star_gift_craft_auction.go b/internal/store/postgres/star_gift_craft_auction.go new file mode 100644 index 00000000..229ba392 --- /dev/null +++ b/internal/store/postgres/star_gift_craft_auction.go @@ -0,0 +1,1094 @@ +package postgres + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +const ( + starGiftAuctionRoundDuration = 3600 + maxStarGiftAuctionAcquired = 1000 +) + +func defaultStarGiftCraftDraw(upper int) (int, error) { + if upper <= 0 { + return 0, domain.ErrStarGiftCraftUnavailable + } + draw, err := rand.Int(rand.Reader, big.NewInt(int64(upper))) + if err != nil { + return 0, err + } + return int(draw.Int64()), nil +} + +// SweepStarGiftLifecycle advances time-driven aggregates without requiring a +// foreground client RPC. All effects remain local PostgreSQL ledger/message +// mutations; this worker never talks to TON, Fragment, wallets or chain nodes. +func (s *StarGiftLifecycleStore) SweepStarGiftLifecycle(ctx context.Context, now, limit int) error { + if s == nil || s.db == nil || s.messages == nil || now <= 0 || limit <= 0 { + return domain.ErrStarGiftUnavailable + } + if limit > 10000 { + limit = 10000 + } + // Payment forms are short-lived intents, not permanent receipts. Committed + // purchases replay from star_gift_purchase_commands/private-send receipts, + // so expired form rows can be removed independently in a bounded batch. + formLimit := limit + if formLimit > 1000 { + formLimit = 1000 + } + if _, err := s.db.Exec(ctx, `WITH stale AS ( +SELECT buyer_user_id,form_id FROM star_gift_purchase_forms +WHERE expires_at<$1 ORDER BY expires_at,buyer_user_id,form_id +FOR UPDATE SKIP LOCKED LIMIT $2) +DELETE FROM star_gift_purchase_forms f USING stale +WHERE f.buyer_user_id=stale.buyer_user_id AND f.form_id=stale.form_id`, now, formLimit); err != nil { + return err + } + remaining := limit + for remaining > 0 { + batch := remaining + if batch > 100 { + batch = 100 + } + count, err := s.expireStarGiftOffersBatch(ctx, now, batch) + if err != nil { + return err + } + remaining -= count + if count < batch { + break + } + } + for remaining > 0 { + batch := remaining + if batch > 100 { + batch = 100 + } + count, err := s.dispatchStarGiftOfferResolutions(ctx, batch) + if err != nil { + return err + } + remaining -= count + if count < batch { + break + } + } + + auctionLimit := remaining + if auctionLimit > 100 { + auctionLimit = 100 + } + if auctionLimit > 0 { + rows, err := s.db.Query(ctx, `SELECT gift_id FROM star_gift_auctions +WHERE (status='pending' AND start_date<=$1) OR + (status='active' AND (next_round_at<=$1 OR end_date<=$1)) +ORDER BY next_round_at,gift_id LIMIT $2`, now, auctionLimit) + if err != nil { + return err + } + giftIDs := make([]int64, 0, auctionLimit) + for rows.Next() { + var giftID int64 + if err := rows.Scan(&giftID); err != nil { + rows.Close() + return err + } + giftIDs = append(giftIDs, giftID) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + for _, giftID := range giftIDs { + if err := s.settleStarGiftAuction(ctx, giftID, now); err != nil { + return err + } + if err := s.dispatchStarGiftAuctionAwards(ctx, giftID); err != nil { + return err + } + } + remaining -= len(giftIDs) + } + + // A prior process can commit award rows and stop before delivery. Drain those + // rows even if their auction clock is no longer due. + if remaining > 0 { + rows, err := s.db.Query(ctx, `SELECT DISTINCT gift_id FROM star_gift_auction_acquired +WHERE saved_gift_id IS NULL ORDER BY gift_id LIMIT $1`, minAuctionInt(remaining, 100)) + if err != nil { + return err + } + giftIDs := make([]int64, 0) + for rows.Next() { + var giftID int64 + if err := rows.Scan(&giftID); err != nil { + rows.Close() + return err + } + giftIDs = append(giftIDs, giftID) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + for _, giftID := range giftIDs { + if err := s.dispatchStarGiftAuctionAwards(ctx, giftID); err != nil { + return err + } + } + } + return nil +} + +func (s *StarGiftLifecycleStore) ListCraftStarGifts(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) { + if s == nil || s.db == nil || userID <= 0 || giftID <= 0 || limit <= 0 || limit > domain.MaxSavedStarGiftsLimit || len(offset) > domain.MaxStarGiftsOffsetBytes { + return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable + } + args := []any{userID, giftID} + where := `p.owner_peer_type='user' AND p.owner_peer_id=$1 AND p.gift_id=$2 + AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer + AND NOT u.burned AND u.owner_address='' AND u.craft_chance_permille>0 + AND EXISTS (SELECT 1 FROM star_gift_collectible_models m + WHERE m.collectible_revision_id=u.collectible_revision_id AND m.crafted)` + var total int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE `+where, args...).Scan(&total); err != nil { + return domain.SavedStarGiftPage{}, fmt.Errorf("count craft star gifts: %w", err) + } + if cursor, ok := domain.DecodeStarGiftCursor(offset); ok { + args = append(args, cursor) + where += fmt.Sprintf(" AND p.id<$%d", len(args)) + } else if offset != "" { + return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable + } + args = append(args, limit+1) + rows, err := s.db.Query(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, +p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, +p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, +p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, +COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i +JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) +FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE `+where+` +ORDER BY p.id DESC LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.SavedStarGiftPage{}, fmt.Errorf("list craft star gifts: %w", err) + } + defer rows.Close() + gifts := make([]domain.SavedStarGift, 0, limit+1) + uniqueIDs := make([]int64, 0, limit+1) + for rows.Next() { + gift, scanErr := scanSavedStarGift(rows) + if scanErr != nil { + return domain.SavedStarGiftPage{}, scanErr + } + gifts = append(gifts, gift) + uniqueIDs = append(uniqueIDs, gift.UniqueGiftID) + } + if err := rows.Err(); err != nil { + return domain.SavedStarGiftPage{}, err + } + hasMore := len(gifts) > limit + if hasMore { + gifts, uniqueIDs = gifts[:limit], uniqueIDs[:limit] + } + uniqueByID, err := NewStarGiftStore(s.db).UniqueByIDs(ctx, uniqueIDs) + if err != nil { + return domain.SavedStarGiftPage{}, err + } + for i := range gifts { + unique, ok := uniqueByID[gifts[i].UniqueGiftID] + if !ok { + return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable + } + gifts[i].Unique = &unique + } + page := domain.SavedStarGiftPage{Count: total, Gifts: gifts} + if hasMore && len(gifts) > 0 { + page.NextOffset = domain.EncodeStarGiftCursor(gifts[len(gifts)-1].ID) + } + return page, nil +} + +func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) { + if s == nil || s.db == nil || s.messages == nil || s.craftDraw == nil || req.UserID <= 0 || len(req.Refs) < 1 || len(req.Refs) > 4 || req.Date <= 0 || + strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + owner := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID} + for _, ref := range req.Refs { + if !ref.Valid() || ref.Owner != owner { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + } + // A committed failed craft has already moved every input out of the active + // lifecycle. Consult the immutable receipt before active-gift resolution so + // an exact transport retry can still replay the same terminal result. + if replay, found, err := s.loadCraftReplay(ctx, req); err != nil || found { + if err != nil || !replay.Success { + return replay, err + } + return s.deliverCraftSuccess(ctx, req, replay) + } + savedIDs, err := NewStarGiftStore(s.db).ResolveSavedIDs(ctx, owner, req.Refs) + if err != nil { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + if len(sortedUniqueInt64(savedIDs)) != len(savedIDs) { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + var result domain.StarGiftCraftResult + var resultUniqueID int64 + err = withTx(ctx, s.db, "craft star gift", func(tx pgx.Tx) error { + lockedRows, err := tx.Query(ctx, `SELECT id FROM peer_star_gifts WHERE id=ANY($1::bigint[]) ORDER BY id FOR UPDATE`, sortedUniqueInt64(savedIDs)) + if err != nil { + return err + } + locked := 0 + for lockedRows.Next() { + locked++ + } + lockedRows.Close() + if locked != len(savedIDs) { + return domain.ErrStarGiftCraftUnavailable + } + + savedByID := make(map[int64]domain.SavedStarGift, len(savedIDs)) + uniqueIDs := make([]int64, 0, len(savedIDs)) + var giftID, revisionID int64 + chance := 0 + for i := range req.Refs { + saved, err := lockSavedStarGiftByID(ctx, tx, savedIDs[i]) + if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt > req.Date { + return domain.ErrStarGiftCraftUnavailable + } + unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID) + if err != nil || !found || unique.Owner != owner || unique.Burned || unique.OwnerAddress != "" || unique.CraftChancePermille <= 0 { + return domain.ErrStarGiftCraftUnavailable + } + if giftID == 0 { + giftID, revisionID = unique.GiftID, unique.CollectibleRevisionID + } else if unique.GiftID != giftID || unique.CollectibleRevisionID != revisionID { + return domain.ErrStarGiftCraftUnavailable + } + savedByID[saved.ID] = saved + uniqueIDs = append(uniqueIDs, unique.ID) + chance += unique.CraftChancePermille + } + if chance > 1000 { + chance = 1000 + } + var craftable bool + if err := tx.QueryRow(ctx, `SELECT EXISTS ( +SELECT 1 FROM star_gift_collectible_models +WHERE collectible_revision_id=$1 AND crafted +)`, revisionID).Scan(&craftable); err != nil { + return err + } + if !craftable { + return domain.ErrStarGiftCraftUnavailable + } + draw, err := s.craftDraw(1000) + if err != nil { + return fmt.Errorf("draw star gift craft outcome: %w", err) + } + result.Chance = chance + result.Success = draw < chance + + if _, err := tx.Exec(ctx, `SELECT id FROM unique_star_gifts WHERE id=ANY($1::bigint[]) ORDER BY id FOR UPDATE`, sortedUniqueInt64(uniqueIDs)); err != nil { + return err + } + // TDesktop deliberately keeps Craft available after an owner lists a + // collectible. Consuming the gift therefore closes every market claim in + // the same transaction: pending buyers are refunded before their offers + // are cancelled, listings disappear, and the catalog resale projection is + // refreshed before any input is crafted or burned. + for _, uniqueID := range uniqueIDs { + if err := s.refundPendingStarGiftOffers(ctx, tx, uniqueID, req.Date, "gift crafted"); err != nil { + return err + } + } + if _, err := tx.Exec(ctx, `DELETE FROM star_gift_listings WHERE unique_gift_id=ANY($1::bigint[])`, uniqueIDs); err != nil { + return err + } + if err := updateStarGiftResaleProjection(ctx, tx, giftID); err != nil { + return err + } + for _, savedID := range savedIDs { + saved := savedByID[savedID] + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + } + + firstSavedID, firstUniqueID := savedIDs[0], uniqueIDs[0] + if result.Success { + modelID, err := chooseCraftedModel(ctx, tx, revisionID) + if err != nil { + return err + } + patternID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revisionID) + if err != nil { + return err + } + backdropID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revisionID) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET model_attribute_id=$2,pattern_attribute_id=$3, +backdrop_attribute_id=$4,crafted=true,craft_chance_permille=0,updated_at=now() WHERE id=$1`, firstUniqueID, modelID, patternID, backdropID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, firstSavedID); err != nil { + return err + } + } + burnFrom := 0 + if result.Success { + burnFrom = 1 + } + if burnFrom < len(uniqueIDs) { + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET burned=true,craft_chance_permille=0, +offer_min_stars=0,updated_at=now() WHERE id=ANY($1::bigint[])`, uniqueIDs[burnFrom:]); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='burned',unsaved=true,pinned_order=0, +transfer_stars=0,can_export_at=0,can_transfer_at=0,can_resell_at=0,drop_original_details_stars=0,can_craft_at=0 +WHERE id=ANY($1::bigint[])`, savedIDs[burnFrom:]); err != nil { + return err + } + } + sourceEdits, sourceEditPTS, err := s.markCraftInputMessagesTx(ctx, tx, req, savedIDs) + if err != nil { + return err + } + result.SourceEdits = sourceEdits + var resultID any + if result.Success { + resultID = firstUniqueID + resultUniqueID = firstUniqueID + } + _, err = tx.Exec(ctx, `INSERT INTO star_gift_craft_commands(user_id,command_key,input_unique_gift_ids,gift_id, +success,result_unique_gift_id,chance_permille,created_at,source_edit_pts) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, + strings.TrimSpace(req.CommandKey), uniqueIDs, giftID, result.Success, resultID, chance, req.Date, sourceEditPTS) + return err + }) + if err != nil { + if isUniqueViolation(err) { + if replay, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found { + return replay, replayErr + } + } + return domain.StarGiftCraftResult{}, err + } + if result.Success { + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, resultUniqueID) + if err != nil || !found { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + result.Gift = &gift + } + if result.Success { + return s.deliverCraftSuccess(ctx, req, result) + } + return result, nil +} + +func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult) (domain.StarGiftCraftResult, error) { + if result.Gift == nil || s.messages == nil { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + saved, found, err := savedStarGiftByUniqueID(ctx, s.db, result.Gift.ID) + if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + sent, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: req.UserID, + RecipientUserID: req.UserID, RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ + Gift: *result.Gift, FromUserID: req.UserID, Peer: saved.Owner, Saved: !saved.Unsaved, Craft: true, + CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, + CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars, + CanCraftAt: saved.CanCraftAt}}}}) + if err != nil { + return domain.StarGiftCraftResult{}, err + } + result.Send = sent + result.Duplicate = result.Duplicate || sent.Duplicate + return result, nil +} + +func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, bool, error) { + var success bool + var resultID *int64 + var chance int + var inputUniqueIDs []int64 + var sourceEditPTS []int32 + err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts +FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`, + req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftCraftResult{}, false, nil + } + if err != nil { + return domain.StarGiftCraftResult{}, false, err + } + if len(req.Refs) != len(inputUniqueIDs) || len(req.Refs) != len(sourceEditPTS) { + return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + } + savedIDs := make([]int64, 0, len(inputUniqueIDs)) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID} + for i, uniqueID := range inputUniqueIDs { + saved, found, err := savedStarGiftByUniqueID(ctx, s.db, uniqueID) + if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != uniqueID { + return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + } + ref := req.Refs[i] + if ref.Owner != owner || ref.Slug == "" && ref.MsgID != saved.MsgID { + return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + } + if ref.Slug != "" { + unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) + if err != nil || !found || !strings.EqualFold(ref.Slug, unique.Slug) { + return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + } + } + savedIDs = append(savedIDs, saved.ID) + } + sourceEdits, err := s.loadCraftInputMessageReplays(ctx, req, savedIDs, sourceEditPTS) + if err != nil { + return domain.StarGiftCraftResult{}, false, err + } + result := domain.StarGiftCraftResult{Success: success, Chance: chance, SourceEdits: sourceEdits, Duplicate: true} + if resultID != nil { + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, *resultID) + if err != nil || !found { + return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + } + result.Gift = &gift + } + return result, true, nil +} + +func chooseCraftedModel(ctx context.Context, tx pgx.Tx, revisionID int64) (int64, error) { + var count int64 + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_collectible_models WHERE collectible_revision_id=$1 AND crafted`, revisionID).Scan(&count); err != nil { + return 0, err + } + if count == 0 { + return 0, domain.ErrStarGiftCraftUnavailable + } + draw, err := rand.Int(rand.Reader, big.NewInt(count)) + if err != nil { + return 0, err + } + var id int64 + if err := tx.QueryRow(ctx, `SELECT id FROM star_gift_collectible_models WHERE collectible_revision_id=$1 AND crafted ORDER BY sort_order,id OFFSET $2 LIMIT 1`, revisionID, draw.Int64()).Scan(&id); err != nil { + return 0, err + } + return id, nil +} + +// settleStarGiftAuction lazily advances every elapsed round. Auction rows are +// the clock aggregate; acquired rows are a durable delivery outbox. A winner's +// reserved bid is consumed, while bids that can no longer reach any remaining +// gift are refunded atomically with the state transition. +func (s *StarGiftLifecycleStore) settleStarGiftAuction(ctx context.Context, giftID int64, now int) error { + if giftID <= 0 || now <= 0 { + return domain.ErrStarGiftAuctionUnavailable + } + return withTx(ctx, s.db, "settle star gift auction", func(tx pgx.Tx) error { + var startDate, endDate, roundDuration, giftsPerRound, totalRounds, currentRound, nextRoundAt, lastGiftNum, giftsLeft int + var status string + if err := tx.QueryRow(ctx, `SELECT start_date,end_date,round_duration,gifts_per_round,total_rounds,current_round, +next_round_at,last_gift_num,gifts_left,status FROM star_gift_auctions WHERE gift_id=$1 FOR UPDATE`, giftID). + Scan(&startDate, &endDate, &roundDuration, &giftsPerRound, &totalRounds, ¤tRound, + &nextRoundAt, &lastGiftNum, &giftsLeft, &status); err != nil { + return err + } + if status == "cancelled" || status == "completed" { + return nil + } + if now < startDate { + return nil + } + changed := false + if status == "pending" { + status = "active" + changed = true + if currentRound == 0 { + currentRound = 1 + } + } + awardedCount := 0 + for status == "active" && currentRound <= totalRounds && nextRoundAt <= now { + awardLimit := giftsPerRound + if awardLimit > giftsLeft { + awardLimit = giftsLeft + } + type winner struct { + userID, recipientID, amount int64 + recipientType string + bidDate int + hide bool + message string + } + winners := make([]winner, 0, awardLimit) + if awardLimit > 0 { + rows, err := tx.Query(ctx, `SELECT bidder_user_id,recipient_peer_type,recipient_peer_id,amount,bid_date,hide_name,message +FROM star_gift_auction_bids WHERE gift_id=$1 AND active ORDER BY amount DESC,bid_date,bidder_user_id LIMIT $2 FOR UPDATE`, giftID, awardLimit) + if err != nil { + return err + } + for rows.Next() { + var winner winner + if err := rows.Scan(&winner.userID, &winner.recipientType, &winner.recipientID, &winner.amount, + &winner.bidDate, &winner.hide, &winner.message); err != nil { + rows.Close() + return err + } + winners = append(winners, winner) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + } + if len(winners) == 0 { + // No active bid can produce an award in any elapsed round. Fast-forward + // the clock aggregate instead of looping once per (possibly very large) + // official supply round after a long process outage. + through := now + if through > endDate { + through = endDate + } + dueRounds := (through-nextRoundAt)/roundDuration + 1 + remainingRounds := totalRounds - currentRound + 1 + if dueRounds > remainingRounds { + dueRounds = remainingRounds + } + if dueRounds < 1 { + dueRounds = 1 + } + currentRound += dueRounds + nextRoundAt += dueRounds * roundDuration + changed = true + if currentRound > totalRounds || nextRoundAt > endDate { + status = "completed" + } + continue + } + for pos, winner := range winners { + giftNum := lastGiftNum + pos + 1 + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_auction_acquired(gift_id,bidder_user_id,recipient_peer_type, +recipient_peer_id,bid_amount,round,pos,gift_num,acquired_at,hide_name,message) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT(gift_id,round,pos) DO NOTHING`, + giftID, winner.userID, winner.recipientType, winner.recipientID, winner.amount, currentRound, pos+1, + giftNum, nextRoundAt, winner.hide, winner.message); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active=false,returned=false, +acquired_count=acquired_count+1,version=version+1 WHERE gift_id=$1 AND bidder_user_id=$2 AND active`, giftID, winner.userID); err != nil { + return err + } + } + lastGiftNum += len(winners) + giftsLeft -= len(winners) + awardedCount += len(winners) + currentRound++ + nextRoundAt += roundDuration + changed = true + if currentRound > totalRounds || giftsLeft <= 0 || nextRoundAt > endDate { + status = "completed" + } + } + if status == "active" && now >= endDate { + status = "completed" + changed = true + } + // Any active rank beyond all remaining gifts can never win, even after + // higher bids are consumed in later rounds, and is therefore refundable. + refundAll := status == "completed" || giftsLeft <= 0 + if err := s.refundUnreachableAuctionBids(ctx, tx, giftID, giftsLeft, refundAll, now); err != nil { + return err + } + if changed { + if _, err := tx.Exec(ctx, `UPDATE star_gift_auctions SET status=$2,current_round=$3,next_round_at=$4, +last_gift_num=$5,gifts_left=$6,version=version+1,updated_at=now() WHERE gift_id=$1`, giftID, status, + minAuctionInt(currentRound, totalRounds), minAuctionInt(nextRoundAt, endDate), lastGiftNum, giftsLeft); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET availability_remains=$2,last_sale_date=CASE WHEN $3>0 THEN $4 ELSE last_sale_date END, +first_sale_date=CASE WHEN first_sale_date=0 AND $3>0 THEN $4 ELSE first_sale_date END,updated_at=now() WHERE gift_id=$1`, + giftID, giftsLeft, awardedCount, now); err != nil { + return err + } + } + return nil + }) +} + +func (s *StarGiftLifecycleStore) refundUnreachableAuctionBids(ctx context.Context, tx pgx.Tx, giftID int64, giftsLeft int, all bool, date int) error { + offset := giftsLeft + if all { + offset = 0 + } + rows, err := tx.Query(ctx, `SELECT bidder_user_id,recipient_peer_type,recipient_peer_id,amount FROM star_gift_auction_bids +WHERE gift_id=$1 AND active ORDER BY amount DESC,bid_date,bidder_user_id OFFSET $2 FOR UPDATE`, giftID, offset) + if err != nil { + return err + } + type refundable struct { + userID, peerID, amount int64 + peerType string + } + items := make([]refundable, 0) + for rows.Next() { + var item refundable + if err := rows.Scan(&item.userID, &item.peerType, &item.peerID, &item.amount); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + for _, item := range items { + if err := s.creditLifecycleAmount(ctx, tx, item.userID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: item.amount}, + domain.StarsReasonGiftAuction, domain.Peer{Type: domain.PeerType(item.peerType), ID: item.peerID}, date, + "Star gift auction bid refund"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active=false,returned=true,version=version+1 +WHERE gift_id=$1 AND bidder_user_id=$2 AND active`, giftID, item.userID); err != nil { + return err + } + } + return nil +} + +func minAuctionInt(a, b int) int { + if a < b { + return a + } + return b +} + +func (s *StarGiftLifecycleStore) dispatchStarGiftAuctionAwards(ctx context.Context, giftID int64) error { + if s.messages == nil { + return domain.ErrStarGiftAuctionUnavailable + } + gift, found, err := NewStarGiftStore(s.db).CatalogGift(ctx, giftID) + if err != nil || !found { + return domain.ErrStarGiftAuctionUnavailable + } + dispatched := 0 + for dispatched < maxStarGiftAuctionAcquired { + rows, err := s.db.Query(ctx, `SELECT id,bidder_user_id,recipient_peer_type,recipient_peer_id,bid_amount, +round,pos,COALESCE(gift_num,0),acquired_at,hide_name,message FROM star_gift_auction_acquired +WHERE gift_id=$1 AND saved_gift_id IS NULL ORDER BY id LIMIT 100`, giftID) + if err != nil { + return err + } + items := make([]struct { + id, bidder, recipientID, amount int64 + recipientType string + round, pos, giftNum, date int + hide bool + message string + }, 0) + for rows.Next() { + var item struct { + id, bidder, recipientID, amount int64 + recipientType string + round, pos, giftNum, date int + hide bool + message string + } + if err := rows.Scan(&item.id, &item.bidder, &item.recipientType, &item.recipientID, &item.amount, + &item.round, &item.pos, &item.giftNum, &item.date, &item.hide, &item.message); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + if len(items) == 0 { + return nil + } + for _, item := range items { + owner := domain.Peer{Type: domain.PeerType(item.recipientType), ID: item.recipientID} + var msgID int + if owner.Type == domain.PeerTypeUser { + sticker := gift.Sticker + sent, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: item.bidder, + RecipientUserID: owner.ID, RandomID: lifecycleCommandRandomID("auction-award", giftID, item.round, item.pos), + Date: item.date, Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{GiftID: gift.ID, + Stars: gift.Stars, ConvertStars: 0, Title: gift.Title, Sticker: &sticker, Message: item.message, + FromUserID: item.bidder, PeerUserID: owner.ID, To: owner, NameHidden: item.hide, Saved: true, + AuctionAcquired: true, GiftNum: item.giftNum}}}}) + if err != nil { + return err + } + msgID = sent.RecipientMessage.ID + if msgID <= 0 { + msgID = sent.SenderMessage.ID + } + } + if err := withTx(ctx, s.db, "save star gift auction award", func(tx pgx.Tx) error { + var savedID *int64 + if err := tx.QueryRow(ctx, `SELECT saved_gift_id FROM star_gift_auction_acquired WHERE id=$1 FOR UPDATE`, item.id).Scan(&savedID); err != nil { + return err + } + if savedID != nil { + return nil + } + id, err := NewStarGiftStore(tx).Create(ctx, domain.SavedStarGift{Owner: owner, FromUserID: item.bidder, + GiftID: gift.ID, RevisionID: gift.RevisionID, MsgID: msgID, Date: item.date, NameHidden: item.hide, + ConvertStars: 0, Message: item.message, GiftNum: item.giftNum}) + if err != nil { + return err + } + if owner.Type == domain.PeerTypeChannel { + sticker := gift.Sticker + action := domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: &domain.MessageStarGiftAction{ + GiftID: gift.ID, Stars: gift.Stars, ConvertStars: 0, Title: gift.Title, Sticker: &sticker, + Message: item.message, FromUserID: item.bidder, PeerChannelID: owner.ID, SavedID: id, + NameHidden: item.hide, Saved: true, AuctionAcquired: true, GiftNum: item.giftNum, + }} + if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, owner.ID, item.bidder, id, item.date, action); err != nil { + return err + } + } + _, err = tx.Exec(ctx, `UPDATE star_gift_auction_acquired SET saved_gift_id=$2 WHERE id=$1`, item.id, id) + return err + }); err != nil { + return err + } + dispatched++ + } + } + return nil +} + +func (s *StarGiftLifecycleStore) StarGiftAuctionState(ctx context.Context, userID int64, giftID int64, slug string, now int) (domain.StarGiftAuction, error) { + if s == nil || s.db == nil || userID <= 0 || now <= 0 || giftID <= 0 && strings.TrimSpace(slug) == "" { + return domain.StarGiftAuction{}, domain.ErrStarGiftAuctionUnavailable + } + resolvedGiftID, err := s.ensureStarGiftAuction(ctx, giftID, strings.TrimSpace(slug), now) + if err != nil { + return domain.StarGiftAuction{}, err + } + if err := s.settleStarGiftAuction(ctx, resolvedGiftID, now); err != nil { + return domain.StarGiftAuction{}, err + } + if err := s.dispatchStarGiftAuctionAwards(ctx, resolvedGiftID); err != nil { + return domain.StarGiftAuction{}, err + } + return s.loadStarGiftAuction(ctx, userID, resolvedGiftID) +} + +func (s *StarGiftLifecycleStore) ActiveStarGiftAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) { + if userID <= 0 || now <= 0 { + return nil, domain.ErrStarGiftAuctionUnavailable + } + rows, err := s.db.Query(ctx, `SELECT DISTINCT a.gift_id FROM star_gift_auctions a +JOIN star_gift_auction_bids b ON b.gift_id=a.gift_id +WHERE b.bidder_user_id=$1 AND a.end_date>$2 AND a.status<>'cancelled' ORDER BY a.gift_id`, userID, now) + if err != nil { + return nil, err + } + defer rows.Close() + giftIDs := make([]int64, 0) + for rows.Next() { + var giftID int64 + if err := rows.Scan(&giftID); err != nil { + return nil, err + } + giftIDs = append(giftIDs, giftID) + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]domain.StarGiftAuction, 0) + for _, giftID := range giftIDs { + state, err := s.StarGiftAuctionState(ctx, userID, giftID, "", now) + if err != nil { + return nil, err + } + if !state.Finished && state.UserState.BidDate > 0 { + out = append(out, state) + } + } + return out, nil +} + +func (s *StarGiftLifecycleStore) StarGiftAuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) { + if userID <= 0 || giftID <= 0 { + return nil, domain.ErrStarGiftAuctionUnavailable + } + if err := s.dispatchStarGiftAuctionAwards(ctx, giftID); err != nil { + return nil, err + } + rows, err := s.db.Query(ctx, `SELECT recipient_peer_type,recipient_peer_id,acquired_at,bid_amount,round,pos,message, +COALESCE(gift_num,0),hide_name FROM star_gift_auction_acquired WHERE bidder_user_id=$1 AND gift_id=$2 ORDER BY id DESC LIMIT $3`, + userID, giftID, maxStarGiftAuctionAcquired) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]domain.StarGiftAuctionAcquired, 0) + for rows.Next() { + var item domain.StarGiftAuctionAcquired + var peerType string + if err := rows.Scan(&peerType, &item.Peer.ID, &item.Date, &item.BidAmount, &item.Round, &item.Pos, + &item.Message, &item.GiftNum, &item.NameHidden); err != nil { + return nil, err + } + item.Peer.Type = domain.PeerType(peerType) + out = append(out, item) + } + return out, rows.Err() +} + +func (s *StarGiftLifecycleStore) BidStarGiftAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) { + if s == nil || s.db == nil || req.UserID <= 0 || req.GiftID <= 0 || !validLifecyclePeer(req.Peer) || + req.BidAmount <= 0 || req.FormID == 0 || req.Date <= 0 || len([]rune(req.Message)) > 128 { + return domain.StarGiftAuction{}, domain.StarsBalance{}, domain.ErrStarGiftAuctionUnavailable + } + if _, err := s.ensureStarGiftAuction(ctx, req.GiftID, "", req.Date); err != nil { + return domain.StarGiftAuction{}, domain.StarsBalance{}, err + } + if err := s.settleStarGiftAuction(ctx, req.GiftID, req.Date); err != nil { + return domain.StarGiftAuction{}, domain.StarsBalance{}, err + } + if balance, found, err := s.loadAuctionBidReplay(ctx, req.UserID, req.FormID, req.GiftID); err != nil || found { + if err != nil { + return domain.StarGiftAuction{}, domain.StarsBalance{}, err + } + state, stateErr := s.loadStarGiftAuction(ctx, req.UserID, req.GiftID) + return state, balance, stateErr + } + var balance domain.StarsBalance + err := withTx(ctx, s.db, "bid star gift auction", func(tx pgx.Tx) error { + var startDate, endDate int + var minimum int64 + var status string + if err := tx.QueryRow(ctx, `SELECT start_date,end_date,min_bid_amount,status FROM star_gift_auctions WHERE gift_id=$1 FOR UPDATE`, req.GiftID). + Scan(&startDate, &endDate, &minimum, &status); err != nil { + return err + } + if status != "active" || req.Date < startDate || req.Date >= endDate || req.BidAmount < minimum { + return domain.ErrStarGiftAuctionUnavailable + } + var oldAmount int64 + var oldActive bool + err := tx.QueryRow(ctx, `SELECT amount,active FROM star_gift_auction_bids WHERE gift_id=$1 AND bidder_user_id=$2 FOR UPDATE`, req.GiftID, req.UserID).Scan(&oldAmount, &oldActive) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if oldActive && (!req.UpdateBid || req.BidAmount <= oldAmount) || !oldActive && req.UpdateBid { + return domain.ErrStarGiftAuctionUnavailable + } + reserved := int64(0) + if oldActive { + reserved = oldAmount + } + delta := req.BidAmount - reserved + balance, err = s.debitLifecycleAmount(ctx, tx, req.UserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: delta}, domain.StarsReasonGiftAuction, + req.Peer, req.Date, "Star gift auction bid") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_auction_bids(gift_id,bidder_user_id,recipient_peer_type,recipient_peer_id, +amount,bid_date,hide_name,message) VALUES($1,$2,$3,$4,$5,$6,$7,$8) +ON CONFLICT(gift_id,bidder_user_id) DO UPDATE SET +recipient_peer_type=CASE WHEN star_gift_auction_bids.active THEN star_gift_auction_bids.recipient_peer_type ELSE EXCLUDED.recipient_peer_type END, +recipient_peer_id=CASE WHEN star_gift_auction_bids.active THEN star_gift_auction_bids.recipient_peer_id ELSE EXCLUDED.recipient_peer_id END, +amount=EXCLUDED.amount,bid_date=EXCLUDED.bid_date, +hide_name=CASE WHEN star_gift_auction_bids.active THEN star_gift_auction_bids.hide_name ELSE EXCLUDED.hide_name END, +message=CASE WHEN star_gift_auction_bids.active THEN star_gift_auction_bids.message ELSE EXCLUDED.message END, +returned=false,active=true,version=star_gift_auction_bids.version+1`, + req.GiftID, req.UserID, string(req.Peer.Type), req.Peer.ID, req.BidAmount, req.Date, req.HideName, req.Message); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_auction_bid_payments(user_id,form_id,gift_id,bid_amount,balance_after,created_at) +VALUES($1,$2,$3,$4,$5,$6)`, req.UserID, req.FormID, req.GiftID, req.BidAmount, balance.Balance, req.Date); err != nil { + return err + } + _, err = tx.Exec(ctx, `UPDATE star_gift_auctions SET version=version+1,updated_at=now() WHERE gift_id=$1`, req.GiftID) + return err + }) + if err != nil { + if isUniqueViolation(err) { + if replayBalance, found, replayErr := s.loadAuctionBidReplay(ctx, req.UserID, req.FormID, req.GiftID); replayErr != nil || found { + state, stateErr := s.loadStarGiftAuction(ctx, req.UserID, req.GiftID) + if replayErr != nil { + return domain.StarGiftAuction{}, domain.StarsBalance{}, replayErr + } + return state, replayBalance, stateErr + } + } + return domain.StarGiftAuction{}, domain.StarsBalance{}, err + } + state, err := s.loadStarGiftAuction(ctx, req.UserID, req.GiftID) + return state, balance, err +} + +func (s *StarGiftLifecycleStore) ensureStarGiftAuction(ctx context.Context, giftID int64, slug string, now int) (int64, error) { + if giftID == 0 { + if err := s.db.QueryRow(ctx, `SELECT gift_id FROM star_gift_catalog_revisions WHERE auction AND auction_slug=$1 ORDER BY id DESC LIMIT 1`, slug).Scan(&giftID); err != nil { + return 0, domain.ErrStarGiftAuctionUnavailable + } + } + gift, found, err := NewStarGiftStore(s.db).CatalogGift(ctx, giftID) + if err != nil || !found || !gift.Auction || gift.GiftsPerRound <= 0 || gift.AuctionSlug == "" { + return 0, domain.ErrStarGiftAuctionUnavailable + } + if slug != "" && slug != gift.AuctionSlug { + return 0, domain.ErrStarGiftAuctionUnavailable + } + supply := gift.AvailabilityTotal + if supply <= 0 { + supply = gift.UpgradeTotal + } + if supply <= 0 { + return 0, domain.ErrStarGiftAuctionUnavailable + } + start := gift.AuctionStartDate + if start <= 0 { + start = now + } + totalRounds := (supply + gift.GiftsPerRound - 1) / gift.GiftsPerRound + end := start + totalRounds*starGiftAuctionRoundDuration + status := "pending" + currentRound := 0 + if now >= start { + status, currentRound = "active", 1 + } + _, err = s.db.Exec(ctx, `INSERT INTO star_gift_auctions(gift_id,slug,start_date,end_date,round_duration,gifts_per_round, +total_rounds,current_round,next_round_at,gifts_left,min_bid_amount,status) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT(gift_id) DO NOTHING`, gift.ID, gift.AuctionSlug, + start, end, starGiftAuctionRoundDuration, gift.GiftsPerRound, totalRounds, currentRound, + start+starGiftAuctionRoundDuration, supply, maxInt64(1, gift.Stars), status) + if err != nil { + return 0, err + } + return gift.ID, nil +} + +func (s *StarGiftLifecycleStore) loadStarGiftAuction(ctx context.Context, userID, giftID int64) (domain.StarGiftAuction, error) { + gift, found, err := NewStarGiftStore(s.db).CatalogGift(ctx, giftID) + if err != nil || !found { + return domain.StarGiftAuction{}, domain.ErrStarGiftAuctionUnavailable + } + out := domain.StarGiftAuction{Gift: gift} + var status string + if err := s.db.QueryRow(ctx, `SELECT version,start_date,end_date,min_bid_amount,next_round_at,last_gift_num,gifts_left, +current_round,total_rounds,round_duration,status FROM star_gift_auctions WHERE gift_id=$1`, giftID).Scan(&out.Version, &out.StartDate, + &out.EndDate, &out.MinBidAmount, &out.NextRoundAt, &out.LastGiftNum, &out.GiftsLeft, &out.CurrentRound, + &out.TotalRounds, &out.RoundDuration, &status); err != nil { + return domain.StarGiftAuction{}, err + } + out.Finished = status == "completed" || status == "cancelled" + if out.Finished { + if err := s.db.QueryRow(ctx, `SELECT COALESCE(AVG(bid_amount)::bigint,0) FROM star_gift_auction_acquired WHERE gift_id=$1`, giftID). + Scan(&out.AveragePrice); err != nil { + return domain.StarGiftAuction{}, err + } + if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id WHERE u.gift_id=$1`, giftID). + Scan(&out.ListedCount); err != nil { + return domain.StarGiftAuction{}, err + } + } + rows, err := s.db.Query(ctx, `SELECT amount,bid_date FROM star_gift_auction_bids WHERE gift_id=$1 AND active +ORDER BY amount DESC,bid_date,bidder_user_id LIMIT 20`, giftID) + if err != nil { + return domain.StarGiftAuction{}, err + } + for rows.Next() { + var level domain.StarGiftAuctionBidLevel + level.Pos = len(out.BidLevels) + 1 + if err := rows.Scan(&level.Amount, &level.Date); err != nil { + rows.Close() + return domain.StarGiftAuction{}, err + } + out.BidLevels = append(out.BidLevels, level) + } + rows.Close() + topRows, err := s.db.Query(ctx, `SELECT bidder_user_id FROM star_gift_auction_bids WHERE gift_id=$1 AND active +ORDER BY amount DESC,bid_date,bidder_user_id LIMIT 3`, giftID) + if err != nil { + return domain.StarGiftAuction{}, err + } + for topRows.Next() { + var id int64 + if err := topRows.Scan(&id); err != nil { + topRows.Close() + return domain.StarGiftAuction{}, err + } + out.TopBidders = append(out.TopBidders, id) + } + topRows.Close() + var peerType string + var active bool + err = s.db.QueryRow(ctx, `SELECT returned,active,amount,bid_date,recipient_peer_type,recipient_peer_id,acquired_count +FROM star_gift_auction_bids WHERE gift_id=$1 AND bidder_user_id=$2`, giftID, userID).Scan(&out.UserState.Returned, + &active, &out.UserState.BidAmount, &out.UserState.BidDate, &peerType, &out.UserState.BidPeer.ID, &out.UserState.AcquiredCount) + if err == nil { + if active || out.UserState.Returned { + out.UserState.BidPeer.Type = domain.PeerType(peerType) + out.UserState.MinBidAmount = out.UserState.BidAmount + 1 + } else { + out.UserState.BidAmount, out.UserState.BidDate, out.UserState.BidPeer = 0, 0, domain.Peer{} + } + } else if !errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftAuction{}, err + } + return out, nil +} + +func (s *StarGiftLifecycleStore) loadAuctionBidReplay(ctx context.Context, userID, formID, giftID int64) (domain.StarsBalance, bool, error) { + var storedGiftID, balance int64 + err := s.db.QueryRow(ctx, `SELECT gift_id,balance_after FROM star_gift_auction_bid_payments WHERE user_id=$1 AND form_id=$2`, userID, formID). + Scan(&storedGiftID, &balance) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarsBalance{}, false, nil + } + if err != nil { + return domain.StarsBalance{}, false, err + } + if storedGiftID != giftID { + return domain.StarsBalance{}, false, domain.ErrStarGiftAuctionUnavailable + } + return domain.StarsBalance{UserID: userID, Balance: balance}, true, nil +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/internal/store/postgres/star_gift_craft_projection.go b/internal/store/postgres/star_gift_craft_projection.go new file mode 100644 index 00000000..eed8a216 --- /dev/null +++ b/internal/store/postgres/star_gift_craft_projection.go @@ -0,0 +1,227 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// markCraftInputMessagesTx makes the chat projection part of the same commit +// as the craft outcome. TDesktop derives the Craft entry directly from the +// messageActionStarGiftUnique snapshot, so changing only peer_star_gifts and +// unique_star_gifts would leave an already-burned input actionable. +func (s *StarGiftLifecycleStore) markCraftInputMessagesTx( + ctx context.Context, + tx pgx.Tx, + req domain.StarGiftCraftRequest, + savedIDs []int64, +) ([]domain.EditedMessageForUser, []int32, error) { + edits := make([]domain.EditedMessageForUser, 0, len(savedIDs)*2) + ownerPTS := make([]int32, 0, len(savedIDs)) + for _, savedID := range savedIDs { + saved, found, err := savedStarGiftByID(ctx, tx, savedID) + if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) || + saved.UniqueGiftID <= 0 || saved.UpgradeMsgID <= 0 { + if err != nil { + return nil, nil, err + } + return nil, nil, domain.ErrStarGiftCraftUnavailable + } + unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID) + if err != nil || !found { + if err != nil { + return nil, nil, err + } + return nil, nil, domain.ErrStarGiftCraftUnavailable + } + inputEdits, ownerPT, err := s.markCraftInputMessageTx(ctx, tx, req, saved, unique) + if err != nil { + return nil, nil, err + } + edits = append(edits, inputEdits...) + ownerPTS = append(ownerPTS, int32(ownerPT)) + } + return edits, ownerPTS, nil +} + +func (s *StarGiftLifecycleStore) markCraftInputMessageTx( + ctx context.Context, + tx pgx.Tx, + req domain.StarGiftCraftRequest, + saved domain.SavedStarGift, + unique domain.UniqueStarGift, +) ([]domain.EditedMessageForUser, int, error) { + q := sqlcgen.New(tx) + target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{ + OwnerUserID: req.UserID, + BoxID: int32(saved.UpgradeMsgID), + PeerType: string(domain.PeerTypeUser), + PeerID: saved.FromUserID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, 0, domain.ErrStarGiftCraftUnavailable + } + return nil, 0, fmt.Errorf("lock craft input message: %w", err) + } + boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: privateMessageOwnerIDs(req.UserID, saved.FromUserID), + MessageSenderID: target.MessageSenderID, + PrivateMessageID: target.PrivateMessageID, + }) + if err != nil { + return nil, 0, fmt.Errorf("list craft input message boxes: %w", err) + } + if len(boxes) == 0 { + return nil, 0, domain.ErrStarGiftCraftUnavailable + } + + edits := make([]domain.EditedMessageForUser, 0, len(boxes)) + ownerPTS := 0 + var privateMediaJSON []byte + for _, box := range boxes { + media, err := decodeMessageMedia(box.MediaJson) + if err != nil { + return nil, 0, fmt.Errorf("decode craft input message media: %w", err) + } + if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil || + media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil || + media.ServiceAction.StarGiftUnique.Gift.ID != unique.ID { + return nil, 0, fmt.Errorf("craft input message %d has invalid unique gift projection", box.BoxID) + } + action := media.ServiceAction.StarGiftUnique + action.Gift = unique + action.Saved = saved.LifecycleStatus.Live() && !saved.Unsaved + action.CanExportAt = saved.CanExportAt + action.TransferStars = saved.TransferStars + action.CanTransferAt = saved.CanTransferAt + action.CanResellAt = saved.CanResellAt + action.DropOriginalDetailsStars = saved.DropOriginalDetailsStars + action.CanCraftAt = saved.CanCraftAt + + mediaJSON, err := encodeMessageMedia(media) + if err != nil { + return nil, 0, fmt.Errorf("encode craft input message media: %w", err) + } + pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID) + if err != nil { + return nil, 0, fmt.Errorf("allocate craft input edit pts: %w", err) + } + tag, err := tx.Exec(ctx, ` +UPDATE message_boxes SET media=$3,pts=$4 +WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts)) + if err != nil { + return nil, 0, fmt.Errorf("update craft input message box: %w", err) + } + if tag.RowsAffected() != 1 { + return nil, 0, fmt.Errorf("update craft input message box lost row") + } + msg, err := messageFromVisibleBoxRow(box) + if err != nil { + return nil, 0, err + } + msg.Media = media + msg.Pts = pts + if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil { + return nil, 0, err + } + event := domain.UpdateEvent{UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage, + Pts: pts, PtsCount: 1, Date: req.Date, Message: msg} + if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil { + return nil, 0, fmt.Errorf("append craft input edit event: %w", err) + } + dispatchAuthKeyID := [8]byte{} + dispatchSessionID := int64(0) + if msg.OwnerUserID == req.UserID { + dispatchAuthKeyID = req.OriginAuthKeyID + dispatchSessionID = req.OriginSessionID + ownerPTS = pts + } + if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{ + TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage), + ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), ExcludeSessionID: dispatchSessionID, + }); err != nil { + return nil, 0, fmt.Errorf("enqueue craft input edit: %w", err) + } + if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 { + privateMediaJSON = mediaJSON + } + edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event}) + } + if ownerPTS <= 0 || len(privateMediaJSON) == 0 { + return nil, 0, fmt.Errorf("craft input message missing owner projection") + } + if _, err := tx.Exec(ctx, ` +UPDATE private_messages SET media=$3 +WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil { + return nil, 0, fmt.Errorf("update craft input private message: %w", err) + } + return edits, ownerPTS, nil +} + +func (s *StarGiftLifecycleStore) loadCraftInputMessageReplays( + ctx context.Context, + req domain.StarGiftCraftRequest, + savedIDs []int64, + ptsValues []int32, +) ([]domain.EditedMessageForUser, error) { + if len(savedIDs) != len(ptsValues) { + return nil, domain.ErrStarGiftCraftUnavailable + } + edits := make([]domain.EditedMessageForUser, 0, len(savedIDs)) + for i, savedID := range savedIDs { + saved, found, err := savedStarGiftByID(ctx, s.db, savedID) + if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) || + saved.UpgradeMsgID <= 0 || ptsValues[i] <= 0 { + if err != nil { + return nil, err + } + return nil, domain.ErrStarGiftCraftUnavailable + } + var privateMessageID, messageSenderID int64 + err = s.db.QueryRow(ctx, ` +SELECT private_message_id,message_sender_id FROM message_boxes +WHERE owner_user_id=$1 AND box_id=$2 AND peer_type='user' AND peer_id=$3 AND NOT deleted`, + req.UserID, saved.UpgradeMsgID, saved.FromUserID).Scan(&privateMessageID, &messageSenderID) + if errors.Is(err, pgx.ErrNoRows) { + continue + } + if err != nil { + return nil, fmt.Errorf("load craft input replay message: %w", err) + } + boxes, err := sqlcgen.New(s.db).ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: []int64{req.UserID}, MessageSenderID: messageSenderID, PrivateMessageID: privateMessageID, + }) + if err != nil { + return nil, fmt.Errorf("load craft input replay box: %w", err) + } + if len(boxes) != 1 || int(boxes[0].BoxID) != saved.UpgradeMsgID { + return nil, domain.ErrStarGiftCraftUnavailable + } + var eventDate int + err = s.db.QueryRow(ctx, ` +SELECT date FROM user_update_events +WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, + req.UserID, ptsValues[i], saved.UpgradeMsgID).Scan(&eventDate) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrStarGiftCraftUnavailable + } + return nil, fmt.Errorf("load craft input replay event: %w", err) + } + msg, err := messageFromVisibleBoxRow(boxes[0]) + if err != nil { + return nil, err + } + msg.Pts = int(ptsValues[i]) + event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage, + Pts: int(ptsValues[i]), PtsCount: 1, Date: eventDate, Message: msg} + edits = append(edits, domain.EditedMessageForUser{UserID: req.UserID, Message: msg, Event: event}) + } + return edits, nil +} diff --git a/internal/store/postgres/star_gift_entitlements.go b/internal/store/postgres/star_gift_entitlements.go new file mode 100644 index 00000000..b5f71d1d --- /dev/null +++ b/internal/store/postgres/star_gift_entitlements.go @@ -0,0 +1,282 @@ +package postgres + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +func (s *StarGiftLifecycleStore) PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) { + hash = strings.TrimSpace(hash) + if s == nil || s.db == nil || !validLifecyclePeer(owner) || len(hash) < 32 || len(hash) > 256 { + return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable + } + row := s.db.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, +p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, +p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, +p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, +COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i +JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) +FROM peer_star_gifts p WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.prepaid_upgrade_hash=$3`, + string(owner.Type), owner.ID, hash) + saved, err := scanSavedStarGift(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable + } + if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID != 0 || saved.PrepaidUpgradeStars != 0 { + if err != nil { + return domain.SavedStarGift{}, 0, err + } + return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable + } + revision, err := locklessActiveCollectibleRevision(ctx, s.db, saved.GiftID) + if err != nil || revision.UpgradeStars <= 0 || revision.Issued >= revision.SupplyTotal { + return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable + } + return saved, revision.UpgradeStars, nil +} + +func locklessActiveCollectibleRevision(ctx context.Context, db interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, giftID int64) (domain.StarGiftCollectibleRevision, error) { + var revision domain.StarGiftCollectibleRevision + var status string + err := db.QueryRow(ctx, `SELECT r.id,r.gift_id,r.upgrade_stars,r.supply_total,r.issued,r.slug_prefix,r.status +FROM star_gift_catalog c JOIN star_gift_collectible_revisions r ON r.id=c.collectible_revision_id +WHERE c.gift_id=$1`, giftID).Scan(&revision.ID, &revision.GiftID, &revision.UpgradeStars, + &revision.SupplyTotal, &revision.Issued, &revision.SlugPrefix, &status) + if err != nil || status != "published" { + return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable + } + return revision, nil +} + +func (s *StarGiftLifecycleStore) PrepayStarGiftUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) { + req.Hash, req.CommandKey = strings.TrimSpace(req.Hash), strings.TrimSpace(req.CommandKey) + if s == nil || s.messages == nil || req.PayerUserID <= 0 || !validLifecyclePeer(req.Owner) || + len(req.Hash) < 32 || len(req.Hash) > 256 || req.FormID == 0 || req.Date <= 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.ChargeStars < 0 { + return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable + } + if replay, found, err := s.loadPrepaidUpgradeReplay(ctx, req, domain.SendPrivateTextResult{}); err != nil || found { + return replay, err + } + if req.ChargeStars <= 0 { + return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable + } + target, price, err := s.PrepaidUpgradeTarget(ctx, req.Owner, req.Hash) + if err != nil || price != req.ChargeStars { + return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable + } + fingerprint := sha256.Sum256([]byte(fmt.Sprintf("telesrv:star-gift-prepay:v2:%d:%s:%d:%s:%d:%d", req.PayerUserID, + req.Owner.Type, req.Owner.ID, req.Hash, req.FormID, req.ChargeStars))) + placeholder := &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{Saved: true, CanUpgrade: true, UpgradeSeparate: true}}} + messageSenderID, recipientUserID := req.PayerUserID, req.Owner.ID + if req.Owner.Type == domain.PeerTypeChannel { + messageSenderID, recipientUserID = domain.OfficialSystemUserID, req.PayerUserID + } + messageReq := domain.SendPrivateTextRequest{SenderUserID: messageSenderID, RecipientUserID: recipientUserID, + RandomID: lifecycleCommandRandomID("prepay", req.PayerUserID, req.Owner.ID, req.Hash), Media: placeholder, Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.PayerUserID, + IdempotencyFingerprint: fingerprint[:]} + var result domain.StarGiftPrepaidUpgradeResult + hooks := privateSendTxHooks{before: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) error { + locked, err := lockSavedStarGiftByPrepayHash(ctx, tx, req.Owner, req.Hash) + if err != nil || locked.ID != target.ID || !locked.LifecycleStatus.Live() || locked.UniqueGiftID != 0 || locked.PrepaidUpgradeStars != 0 { + return domain.ErrStarGiftCollectibleUnavailable + } + revision, err := lockActiveCollectibleRevision(ctx, tx, locked.GiftID) + if err != nil || revision.UpgradeStars != req.ChargeStars || revision.Issued >= revision.SupplyTotal { + return domain.ErrStarGiftCollectibleUnavailable + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.PayerUserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars}, + domain.StarsReasonGiftPrepaid, req.Owner, req.Date, "Prepaid star gift upgrade") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET prepaid_upgrade_stars=$2,prepaid_upgrade_hash='' WHERE id=$1`, locked.ID, req.ChargeStars); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_prepaid_upgrade_commands(payer_user_id,command_key,saved_gift_id,form_id,charge_stars,balance_after,created_at) +VALUES($1,$2,$3,$4,$5,$6,$7)`, req.PayerUserID, req.CommandKey, locked.ID, req.FormID, req.ChargeStars, balance.Balance, req.Date); err != nil { + return err + } + gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, locked.RevisionID) + if err != nil || !found { + return domain.ErrStarGiftCollectibleUnavailable + } + sticker := gift.Sticker + action := &domain.MessageStarGiftAction{ + GiftID: gift.ID, Stars: gift.Stars, ConvertStars: locked.ConvertStars, Title: gift.Title, Sticker: &sticker, + FromUserID: req.PayerUserID, To: req.Owner, SavedID: locked.SavedID, Saved: true, CanUpgrade: true, + PrepaidUpgrade: true, UpgradeSeparate: true, UpgradePriceStars: req.ChargeStars, + UpgradeStars: req.ChargeStars, GiftMsgID: locked.MsgID, + } + if req.Owner.Type == domain.PeerTypeChannel { + action.PeerChannelID = req.Owner.ID + } else { + action.PeerUserID = req.Owner.ID + } + messageReq.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{ + GiftID: action.GiftID, Stars: action.Stars, ConvertStars: action.ConvertStars, Title: action.Title, + Sticker: action.Sticker, FromUserID: action.FromUserID, PeerUserID: action.PeerUserID, + PeerChannelID: action.PeerChannelID, To: action.To, SavedID: action.SavedID, Saved: action.Saved, + CanUpgrade: action.CanUpgrade, PrepaidUpgrade: action.PrepaidUpgrade, UpgradeSeparate: action.UpgradeSeparate, + UpgradePriceStars: action.UpgradePriceStars, UpgradeStars: action.UpgradeStars, GiftMsgID: action.GiftMsgID}}} + locked.PrepaidUpgradeStars, locked.PrepaidUpgradeHash = req.ChargeStars, "" + result.Saved, result.Balance = locked, balance + return nil + }, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + if req.Owner.Type != domain.PeerTypeChannel { + return nil + } + action := messageReq.Media.ServiceAction.StarGift + return NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.Owner.ID, req.PayerUserID, + result.Saved.SavedID, req.Date, domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: action}) + }} + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + if isUniqueViolation(err) { + if replay, found, replayErr := s.loadPrepaidUpgradeReplay(ctx, req, sent); replayErr != nil || found { + return replay, replayErr + } + } + return domain.StarGiftPrepaidUpgradeResult{}, err + } + result.Send, result.Duplicate = sent, sent.Duplicate + if sent.Duplicate { + replay, _, replayErr := s.loadPrepaidUpgradeReplay(ctx, req, sent) + return replay, replayErr + } + return result, nil +} + +func lockSavedStarGiftByPrepayHash(ctx context.Context, tx pgx.Tx, owner domain.Peer, hash string) (domain.SavedStarGift, error) { + row := tx.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, +p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, +p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, +p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, +COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i +JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) +FROM peer_star_gifts p WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.prepaid_upgrade_hash=$3 FOR UPDATE`, + string(owner.Type), owner.ID, hash) + saved, err := scanSavedStarGift(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SavedStarGift{}, domain.ErrStarGiftCollectibleUnavailable + } + return saved, err +} + +func (s *StarGiftLifecycleStore) loadPrepaidUpgradeReplay(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest, sent domain.SendPrivateTextResult) (domain.StarGiftPrepaidUpgradeResult, bool, error) { + var savedID, balance int64 + err := s.db.QueryRow(ctx, `SELECT saved_gift_id,balance_after FROM star_gift_prepaid_upgrade_commands WHERE payer_user_id=$1 AND command_key=$2`, + req.PayerUserID, req.CommandKey).Scan(&savedID, &balance) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftPrepaidUpgradeResult{}, false, nil + } + if err != nil { + return domain.StarGiftPrepaidUpgradeResult{}, false, err + } + saved, found, err := savedStarGiftByID(ctx, s.db, savedID) + if err != nil || !found { + return domain.StarGiftPrepaidUpgradeResult{}, false, domain.ErrStarGiftCollectibleUnavailable + } + return domain.StarGiftPrepaidUpgradeResult{Saved: saved, Balance: domain.StarsBalance{UserID: req.PayerUserID, Balance: balance}, Send: sent, Duplicate: true}, true, nil +} + +func (s *StarGiftLifecycleStore) DropStarGiftOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) { + req.CommandKey = strings.TrimSpace(req.CommandKey) + if s == nil || s.db == nil || req.UserID <= 0 || !req.Ref.Valid() || + (req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) || !validLifecyclePeer(req.Ref.Owner) || + req.FormID == 0 || req.Date <= 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.ChargeStars < 0 { + return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable + } + if replay, found, err := s.loadDropDetailsReplay(ctx, req); err != nil || found { + return replay, err + } + if req.ChargeStars <= 0 { + return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable + } + var result domain.StarGiftDropOriginalDetailsResult + err := withTx(ctx, s.db, "drop star gift original details", func(tx pgx.Tx) error { + saved, unique, err := lockOwnedUniqueStarGift(ctx, tx, req.UserID, req.Ref) + if err != nil || saved.DropOriginalDetailsStars != req.ChargeStars || !unique.KeepOriginalDetails { + return domain.ErrStarGiftCollectibleUnavailable + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.UserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars}, + domain.StarsReasonGiftDrop, saved.Owner, req.Date, "Drop star gift original details") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET keep_original_details=false,updated_at=now() WHERE id=$1`, unique.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET drop_original_details_stars=0 WHERE id=$1`, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_drop_details_commands(user_id,command_key,saved_gift_id,unique_gift_id,form_id,charge_stars,balance_after,created_at) +VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, req.UserID, req.CommandKey, saved.ID, unique.ID, req.FormID, req.ChargeStars, balance.Balance, req.Date); err != nil { + return err + } + saved.DropOriginalDetailsStars, unique.KeepOriginalDetails = 0, false + result = domain.StarGiftDropOriginalDetailsResult{Saved: saved, Unique: unique, Balance: balance} + return nil + }) + if err != nil { + if isUniqueViolation(err) { + if replay, found, replayErr := s.loadDropDetailsReplay(ctx, req); replayErr != nil || found { + return replay, replayErr + } + } + return domain.StarGiftDropOriginalDetailsResult{}, err + } + return result, nil +} + +func (s *StarGiftLifecycleStore) loadDropDetailsReplay(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, bool, error) { + var savedID, uniqueID, balance int64 + err := s.db.QueryRow(ctx, `SELECT saved_gift_id,unique_gift_id,balance_after FROM star_gift_drop_details_commands WHERE user_id=$1 AND command_key=$2`, + req.UserID, req.CommandKey).Scan(&savedID, &uniqueID, &balance) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftDropOriginalDetailsResult{}, false, nil + } + if err != nil { + return domain.StarGiftDropOriginalDetailsResult{}, false, err + } + saved, found, err := savedStarGiftByID(ctx, s.db, savedID) + if err != nil || !found { + return domain.StarGiftDropOriginalDetailsResult{}, false, domain.ErrStarGiftCollectibleUnavailable + } + unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) + if err != nil || !found { + return domain.StarGiftDropOriginalDetailsResult{}, false, domain.ErrStarGiftCollectibleUnavailable + } + return domain.StarGiftDropOriginalDetailsResult{Saved: saved, Unique: unique, + Balance: domain.StarsBalance{UserID: req.UserID, Balance: balance}, Duplicate: true}, true, nil +} + +func savedStarGiftByID(ctx context.Context, db interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, savedID int64) (domain.SavedStarGift, bool, error) { + row := db.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, +p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, +p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, +p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, +COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i +JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) +FROM peer_star_gifts p WHERE p.id=$1`, savedID) + saved, err := scanSavedStarGift(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SavedStarGift{}, false, nil + } + return saved, err == nil, err +} diff --git a/internal/store/postgres/star_gift_lifecycle.go b/internal/store/postgres/star_gift_lifecycle.go new file mode 100644 index 00000000..d54c4c4d --- /dev/null +++ b/internal/store/postgres/star_gift_lifecycle.go @@ -0,0 +1,1693 @@ +package postgres + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +type StarGiftLifecycleStore struct { + db sqlcgen.DBTX + messages *MessageStore + tonStartingGrant int64 + market domain.StarGiftMarketPolicy + craftDraw func(int) (int, error) +} + +type StarGiftLifecycleOption func(*StarGiftLifecycleStore) + +func WithStarGiftMarketPolicy(policy domain.StarGiftMarketPolicy) StarGiftLifecycleOption { + return func(s *StarGiftLifecycleStore) { + if policy.Valid() { + s.market = policy + } + } +} + +// WithStarGiftCraftDraw replaces the cryptographically random craft draw. +// It exists so integration tests can cover both terminal outcomes without +// probabilistic retries; production constructors use defaultStarGiftCraftDraw. +func WithStarGiftCraftDraw(draw func(int) (int, error)) StarGiftLifecycleOption { + return func(s *StarGiftLifecycleStore) { + if draw != nil { + s.craftDraw = draw + } + } +} + +func NewStarGiftLifecycleStore(db sqlcgen.DBTX, messages *MessageStore, tonStartingGrant int64, opts ...StarGiftLifecycleOption) *StarGiftLifecycleStore { + if tonStartingGrant < 0 { + tonStartingGrant = 0 + } + s := &StarGiftLifecycleStore{db: db, messages: messages, tonStartingGrant: tonStartingGrant, + market: domain.StarGiftMarketPolicy{StarsProceedsPermille: 1000, TONProceedsPermille: 1000}, + craftDraw: defaultStarGiftCraftDraw} + for _, opt := range opts { + opt(s) + } + return s +} + +// ConvertStarGift owns the complete conversion aggregate: saved-gift terminal +// state, collection membership, owner-scoped Stars balance and transaction log. +// A channel conversion credits the channel ledger, never ActorUserID's personal +// balance. No external payment or blockchain system participates. +func (s *StarGiftLifecycleStore) ConvertStarGift(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) { + if s == nil || s.db == nil || req.ActorUserID <= 0 || !req.Ref.Valid() || req.Date <= 0 { + return domain.StarGiftConvertResult{}, domain.ErrStarGiftNotFound + } + if req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.ActorUserID { + return domain.StarGiftConvertResult{}, domain.ErrStarGiftOwnerInvalid + } + if !validLifecyclePeer(req.Ref.Owner) { + return domain.StarGiftConvertResult{}, domain.ErrStarGiftOwnerInvalid + } + + var result domain.StarGiftConvertResult + err := withTx(ctx, s.db, "convert star gift aggregate", func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, starGiftCollectionLockKey(req.Ref.Owner)); err != nil { + return fmt.Errorf("lock star gift owner collections: %w", err) + } + saved, err := lockSavedStarGiftForUpgrade(ctx, tx, req.Ref) + if err != nil { + return err + } + if saved.Converted || saved.LifecycleStatus == domain.StarGiftLifecycleConverted { + return domain.ErrStarGiftAlreadyConverted + } + if !saved.LifecycleStatus.Live() || saved.UniqueGiftID != 0 { + return domain.ErrStarGiftAlreadyUpgraded + } + + from := domain.Peer{Type: domain.PeerTypeUser, ID: saved.FromUserID} + amount := saved.ConvertStars + var balanceAfter int64 + switch saved.Owner.Type { + case domain.PeerTypeUser: + if amount > 0 { + if err := s.creditLifecycleAmount(ctx, tx, saved.Owner.ID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: amount}, + domain.StarsReasonGift, from, req.Date, "Star gift conversion"); err != nil { + return err + } + } + if err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM stars_balances WHERE user_id=$1),0)`, saved.Owner.ID).Scan(&balanceAfter); err != nil { + return err + } + case domain.PeerTypeChannel: + if amount > 0 { + if err := tx.QueryRow(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,$2) + ON CONFLICT(channel_id) DO UPDATE SET balance=channel_stars_balances.balance+EXCLUDED.balance,updated_at=now() + RETURNING balance`, saved.Owner.ID, amount).Scan(&balanceAfter); err != nil { + return fmt.Errorf("credit channel star gift conversion: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO channel_stars_transactions + (channel_id,actor_user_id,amount,reason,peer_type,peer_id,gift_id,date) + VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, saved.Owner.ID, req.ActorUserID, amount, + string(domain.StarsReasonGift), string(from.Type), from.ID, saved.GiftID, req.Date); err != nil { + return fmt.Errorf("record channel star gift conversion: %w", err) + } + } else if err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, saved.Owner.ID).Scan(&balanceAfter); err != nil { + return err + } + } + + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts + SET converted=true,lifecycle_status='converted',unsaved=true,pinned_order=0 + WHERE id=$1`, saved.ID); err != nil { + return fmt.Errorf("mark star gift converted: %w", err) + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_conversions + (saved_gift_id,actor_user_id,owner_peer_type,owner_peer_id,amount,balance_after,converted_at) + VALUES($1,$2,$3,$4,$5,$6,$7)`, saved.ID, req.ActorUserID, string(saved.Owner.Type), + saved.Owner.ID, amount, balanceAfter, req.Date); err != nil { + return fmt.Errorf("record star gift conversion command: %w", err) + } + saved.Converted = true + saved.LifecycleStatus = domain.StarGiftLifecycleConverted + saved.Unsaved = true + saved.PinnedOrder = 0 + saved.CollectionIDs = nil + result = domain.StarGiftConvertResult{Saved: saved, OwnerBalance: balanceAfter} + return nil + }) + if err != nil { + return domain.StarGiftConvertResult{}, err + } + return result, nil +} + +func (s *StarGiftLifecycleStore) ListResaleStarGifts(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) { + if s == nil || s.db == nil || filter.GiftID <= 0 || filter.Limit <= 0 || filter.Limit > domain.MaxSavedStarGiftsLimit || + filter.SortByPrice && filter.SortByNum || len(filter.Offset) > domain.MaxStarGiftsOffsetBytes { + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + conditions := []string{"u.gift_id=$1", "NOT u.burned", "u.owner_address=''"} + args := []any{filter.GiftID} + nextArg := func(value any) string { + args = append(args, value) + return "$" + strconv.Itoa(len(args)) + } + if filter.StarsOnly { + conditions = append(conditions, "l.currency='XTR'") + } + if filter.ForCraft { + conditions = append(conditions, `u.craft_chance_permille>0 AND EXISTS ( +SELECT 1 FROM star_gift_collectible_models model +WHERE model.collectible_revision_id=u.collectible_revision_id AND model.crafted)`) + } + if len(filter.ModelIDs) > 0 { + conditions = append(conditions, "u.model_attribute_id IN (SELECT id FROM star_gift_collectible_models WHERE document_id=ANY("+nextArg(filter.ModelIDs)+"::bigint[]))") + } + if len(filter.PatternIDs) > 0 { + conditions = append(conditions, "u.pattern_attribute_id IN (SELECT id FROM star_gift_collectible_patterns WHERE document_id=ANY("+nextArg(filter.PatternIDs)+"::bigint[]))") + } + if len(filter.BackdropIDs) > 0 { + conditions = append(conditions, "u.backdrop_attribute_id IN (SELECT id FROM star_gift_collectible_backdrops WHERE backdrop_id::bigint=ANY("+nextArg(filter.BackdropIDs)+"::bigint[]))") + } + order := "l.updated_at DESC, u.id DESC" + if filter.SortByPrice { + order = "l.amount, u.id" + } else if filter.SortByNum { + order = "u.num, u.id" + } + if filter.Offset != "" { + parts := strings.Split(filter.Offset, ":") + if len(parts) != 3 { + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + value, valueErr := strconv.ParseInt(parts[1], 10, 64) + id, idErr := strconv.ParseInt(parts[2], 10, 64) + if valueErr != nil || idErr != nil || value < 0 || id <= 0 { + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + switch { + case filter.SortByPrice && parts[0] == "p": + p1, p2 := nextArg(value), nextArg(id) + conditions = append(conditions, "(l.amount,u.id)>("+p1+","+p2+")") + case filter.SortByNum && parts[0] == "n": + p1, p2 := nextArg(value), nextArg(id) + conditions = append(conditions, "(u.num,u.id)>("+p1+","+p2+")") + case !filter.SortByPrice && !filter.SortByNum && parts[0] == "d": + p1, p2 := nextArg(value), nextArg(id) + conditions = append(conditions, "(l.updated_at,u.id)<("+p1+","+p2+")") + default: + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + } + where := strings.Join(conditions, " AND ") + var total int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id WHERE `+where, args...).Scan(&total); err != nil { + return domain.StarGiftResalePage{}, fmt.Errorf("count resale star gifts: %w", err) + } + limitArg := nextArg(filter.Limit + 1) + rows, err := s.db.Query(ctx, `SELECT u.id,l.amount,l.updated_at,u.num +FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id +WHERE `+where+` ORDER BY `+order+` LIMIT `+limitArg, args...) + if err != nil { + return domain.StarGiftResalePage{}, fmt.Errorf("list resale star gifts: %w", err) + } + defer rows.Close() + type listedID struct { + id, amount int64 + updated, num int + } + listed := make([]listedID, 0, filter.Limit+1) + ids := make([]int64, 0, filter.Limit+1) + for rows.Next() { + var item listedID + if err := rows.Scan(&item.id, &item.amount, &item.updated, &item.num); err != nil { + return domain.StarGiftResalePage{}, err + } + listed = append(listed, item) + ids = append(ids, item.id) + } + if err := rows.Err(); err != nil { + return domain.StarGiftResalePage{}, err + } + hasMore := len(listed) > filter.Limit + if hasMore { + listed, ids = listed[:filter.Limit], ids[:filter.Limit] + } + uniqueByID, err := NewStarGiftStore(s.db).UniqueByIDs(ctx, ids) + if err != nil { + return domain.StarGiftResalePage{}, err + } + page := domain.StarGiftResalePage{Count: total, Gifts: make([]domain.UniqueStarGift, 0, len(ids))} + for _, item := range listed { + gift, ok := uniqueByID[item.id] + if !ok { + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + page.Gifts = append(page.Gifts, gift) + } + if hasMore && len(listed) > 0 { + last := listed[len(listed)-1] + switch { + case filter.SortByPrice: + page.NextOffset = fmt.Sprintf("p:%d:%d", last.amount, last.id) + case filter.SortByNum: + page.NextOffset = fmt.Sprintf("n:%d:%d", last.num, last.id) + default: + page.NextOffset = fmt.Sprintf("d:%d:%d", last.updated, last.id) + } + } + return page, nil +} + +func (s *StarGiftLifecycleStore) UniqueStarGiftValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) { + var out domain.StarGiftValueInfo + var configuredCurrency string + var configuredValue int64 + err := s.db.QueryRow(ctx, ` +SELECT sg.gift_date, cr.stars, u.value_currency, u.value_amount, u.last_sale_date, + COALESCE(CASE WHEN u.last_sale_currency='XTR' THEN u.last_sale_amount END,0), + COALESCE((SELECT MIN(l.amount) FROM star_gift_listings l JOIN unique_star_gifts lu ON lu.id=l.unique_gift_id WHERE lu.gift_id=u.gift_id AND l.currency='XTR'),0), + COALESCE((SELECT AVG(sa.amount)::bigint FROM star_gift_sales sa JOIN unique_star_gifts su ON su.id=sa.unique_gift_id WHERE su.gift_id=u.gift_id AND sa.currency='XTR'),0), + (SELECT COUNT(*) FROM star_gift_listings l JOIN unique_star_gifts lu ON lu.id=l.unique_gift_id WHERE lu.gift_id=u.gift_id) +FROM unique_star_gifts u +JOIN peer_star_gifts sg ON sg.id=u.source_saved_gift_id +JOIN star_gift_catalog_revisions cr ON cr.id=sg.catalog_revision_id +WHERE u.id=$1`, uniqueGiftID).Scan(&out.InitialSaleDate, &out.InitialSaleStars, &configuredCurrency, + &configuredValue, &out.LastSaleDate, &out.LastSalePrice, &out.FloorPrice, &out.AveragePrice, &out.ListedCount) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftValueInfo{}, domain.ErrStarGiftNotFound + } + if err != nil { + return domain.StarGiftValueInfo{}, fmt.Errorf("star gift value info: %w", err) + } + // The self-hosted ledger has no FX oracle. One Star-cent is the explicit local + // valuation unit unless an operator/provider has stored a real fiat estimate. + out.Currency = "USD" + out.InitialSalePrice = out.InitialSaleStars + if configuredCurrency != "" && configuredValue > 0 { + out.Currency, out.Value = configuredCurrency, configuredValue + } else if out.LastSalePrice > 0 { + out.Value = out.LastSalePrice + } else if out.FloorPrice > 0 { + out.Value = out.FloorPrice + } else { + out.Value = out.InitialSalePrice + } + out.ValueIsAverage = out.AveragePrice > 0 && out.LastSalePrice == 0 + return out, nil +} + +func (s *StarGiftLifecycleStore) SetStarGiftListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) { + if req.ActorUserID <= 0 || !req.Ref.Valid() || req.Date <= 0 || req.Amount != nil && !req.Amount.Valid() { + return domain.UniqueStarGift{}, domain.ErrStarGiftResaleUnavailable + } + var uniqueID int64 + err := withTx(ctx, s.db, "set star gift listing", func(tx pgx.Tx) error { + saved, err := lockSavedStarGiftForUpgrade(ctx, tx, req.Ref) + if err != nil { + return err + } + if !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanResellAt > req.Date || saved.Owner != req.Ref.Owner { + return domain.ErrStarGiftResaleUnavailable + } + if saved.Owner.Type == domain.PeerTypeUser && saved.Owner.ID != req.ActorUserID { + return domain.ErrStarGiftOwnerInvalid + } + unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID) + if err != nil { + return err + } + if !found || unique.Burned || unique.Owner != saved.Owner || unique.OwnerAddress != "" { + return domain.ErrStarGiftResaleUnavailable + } + uniqueID = unique.ID + if req.Amount == nil { + if _, err := tx.Exec(ctx, `DELETE FROM star_gift_listings WHERE unique_gift_id=$1`, unique.ID); err != nil { + return err + } + } else { + if unique.ResaleTonOnly && req.Amount.Currency != domain.StarGiftCurrencyTON { + return domain.ErrStarGiftResaleUnavailable + } + var minimum int64 + if req.Amount.Currency == domain.StarGiftCurrencyStars { + if err := tx.QueryRow(ctx, `SELECT resell_min_stars FROM star_gift_catalog WHERE gift_id=$1`, unique.GiftID).Scan(&minimum); err != nil { + return err + } + if req.Amount.Amount < minimum { + return domain.ErrStarGiftResaleUnavailable + } + } + _, err = tx.Exec(ctx, `INSERT INTO star_gift_listings(unique_gift_id,seller_peer_type,seller_peer_id,currency,amount,listed_at,updated_at) +VALUES($1,$2,$3,$4,$5,$6,$6) +ON CONFLICT(unique_gift_id) DO UPDATE SET currency=EXCLUDED.currency,amount=EXCLUDED.amount,updated_at=EXCLUDED.updated_at,version=star_gift_listings.version+1`, + unique.ID, string(saved.Owner.Type), saved.Owner.ID, string(req.Amount.Currency), req.Amount.Amount, req.Date) + if err != nil { + return err + } + } + return updateStarGiftResaleProjection(ctx, tx, unique.GiftID) + }) + if err != nil { + return domain.UniqueStarGift{}, err + } + unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) + if err != nil { + return domain.UniqueStarGift{}, err + } + if !found { + return domain.UniqueStarGift{}, domain.ErrStarGiftNotFound + } + return unique, nil +} + +func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) { + if s == nil || s.messages == nil || req.ActorUserID <= 0 || !req.Ref.Valid() || !validLifecyclePeer(req.To) || + req.To == req.Ref.Owner || req.ChargeStars < 0 || req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable + } + if req.To.Type != domain.PeerTypeUser { + return s.transferStarGiftWithoutPrivateMessage(ctx, req) + } + messageReq := domain.SendPrivateTextRequest{ + SenderUserID: req.ActorUserID, RecipientUserID: req.To.ID, + RandomID: lifecycleCommandRandomID("gift-transfer", req.ActorUserID, req.CommandKey), Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.ActorUserID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{Transferred: true, Saved: true}, + }}, + } + var result domain.StarGiftTransferResult + hooks := privateSendTxHooks{ + before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { + saved, unique, err := lockTransferableStarGift(ctx, tx, req.ActorUserID, req.Ref, req.Date) + if err != nil { + return err + } + if saved.TransferStars != req.ChargeStars { + return domain.ErrStarGiftTransferUnavailable + } + if err := ensureNoStarGiftMarketConflict(ctx, tx, unique.ID); err != nil { + return err + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.ActorUserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars}, + domain.StarsReasonGiftTransfer, req.To, req.Date, "Star gift transfer") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type=$2,owner_peer_id=$3,updated_at=now() WHERE id=$1`, + unique.ID, string(req.To.Type), req.To.ID); err != nil { + return err + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + unique.Owner = req.To + saved.Owner = req.To + result.Saved, result.Unique, result.Balance = saved, unique, balance + send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(unique, req.ActorUserID, req.To, saved) + return nil + }, + after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + msgID := sent.RecipientMessage.ID + if req.ActorUserID == req.To.ID { + msgID = sent.SenderMessage.ID + } + if msgID <= 0 { + return domain.ErrStarGiftTransferUnavailable + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type='user',owner_peer_id=$2,from_user_id=$3, + msg_id=$4,saved_id=0,upgrade_msg_id=$4,gift_date=$5,name_hidden=false,unsaved=false,pinned_order=0, + can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_transfer_commands(actor_user_id,command_key,unique_gift_id, + from_peer_type,from_peer_id,to_peer_type,to_peer_id,charge_stars,balance_after,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.ActorUserID, strings.TrimSpace(req.CommandKey), result.Unique.ID, + string(req.Ref.Owner.Type), req.Ref.Owner.ID, string(req.To.Type), req.To.ID, req.ChargeStars, result.Balance.Balance, req.Date); err != nil { + return err + } + result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, 0, msgID, req.Date + result.Saved.FromUserID = req.ActorUserID + return nil + }, + } + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + return domain.StarGiftTransferResult{}, err + } + result.Send, result.Duplicate = sent, sent.Duplicate + if sent.Duplicate { + return s.loadTransferReplay(ctx, req, sent) + } + return result, nil +} + +func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) { + if s == nil || s.messages == nil || req.BuyerUserID <= 0 || strings.TrimSpace(req.Slug) == "" || + !validLifecyclePeer(req.To) || !req.Amount.Valid() || req.FormID == 0 || + strings.TrimSpace(req.CommandKey) == "" || req.Date <= 0 { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable + } + unique, found, err := NewStarGiftStore(s.db).UniqueBySlug(ctx, req.Slug) + if err != nil || !found { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable + } + seller := unique.Owner + var replayUniqueID, replayFromID, replayToID, replayAmount int64 + var replayFromType, replayToType, replayCurrency string + replayErr := s.db.QueryRow(ctx, `SELECT t.unique_gift_id,t.from_peer_type,t.from_peer_id,t.to_peer_type,t.to_peer_id, + s.currency,s.amount FROM star_gift_transfer_commands t + JOIN star_gift_sales s ON s.command_key=t.command_key AND s.unique_gift_id=t.unique_gift_id + WHERE t.actor_user_id=$1 AND t.command_key=$2`, req.BuyerUserID, strings.TrimSpace(req.CommandKey)).Scan( + &replayUniqueID, &replayFromType, &replayFromID, &replayToType, &replayToID, &replayCurrency, &replayAmount) + if replayErr == nil { + if replayUniqueID != unique.ID || replayToType != string(req.To.Type) || replayToID != req.To.ID || + replayCurrency != string(req.Amount.Currency) || replayAmount != req.Amount.Amount { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable + } + seller = domain.Peer{Type: domain.PeerType(replayFromType), ID: replayFromID} + } else if !errors.Is(replayErr, pgx.ErrNoRows) { + return domain.StarGiftTransferResult{}, replayErr + } else if !validLifecyclePeer(unique.Owner) || unique.Owner == req.To { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable + } + messageSenderID := domain.OfficialSystemUserID + if seller.Type == domain.PeerTypeUser { + messageSenderID = seller.ID + } + messageRecipientID := req.BuyerUserID + if req.To.Type == domain.PeerTypeUser { + messageRecipientID = req.To.ID + } + messageReq := domain.SendPrivateTextRequest{ + SenderUserID: messageSenderID, RecipientUserID: messageRecipientID, + RandomID: lifecycleCommandRandomID("gift-resale", req.BuyerUserID, req.CommandKey), Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.BuyerUserID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{Transferred: true, Saved: true}, + }}, + } + var result domain.StarGiftTransferResult + var commissionAmount int64 + hooks := privateSendTxHooks{ + before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { + var listingCurrency, sellerType string + var listingAmount, sellerID, uniqueID int64 + if err := tx.QueryRow(ctx, `SELECT l.currency,l.amount,l.seller_peer_type,l.seller_peer_id,u.id + FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id + WHERE lower(u.slug)=lower($1) FOR UPDATE OF l,u`, strings.TrimSpace(req.Slug)).Scan( + &listingCurrency, &listingAmount, &sellerType, &sellerID, &uniqueID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrStarGiftResaleUnavailable + } + return err + } + if sellerType != string(seller.Type) || sellerID != seller.ID || + listingCurrency != string(req.Amount.Currency) || listingAmount != req.Amount.Amount { + return domain.ErrStarGiftResaleUnavailable + } + saved, found, err := lockSavedStarGiftByUniqueID(ctx, tx, uniqueID) + if err != nil || !found || !saved.LifecycleStatus.Live() || saved.Owner != seller { + return domain.ErrStarGiftResaleUnavailable + } + gift, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID) + if err != nil || !found || gift.Burned || gift.Owner != saved.Owner { + return domain.ErrStarGiftResaleUnavailable + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.BuyerUserID, req.Amount, domain.StarsReasonGiftResale, + saved.Owner, req.Date, "Collectible gift purchase") + if err != nil { + return err + } + if _, commission, err := s.creditPeerLifecycleAmount(ctx, tx, seller, req.BuyerUserID, req.Amount, + domain.StarsReasonGiftResale, domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, + gift.ID, req.Date, "Collectible gift sale"); err != nil { + return err + } else { + commissionAmount = commission + } + if err := s.refundPendingStarGiftOffers(ctx, tx, uniqueID, req.Date, "listing purchased"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM star_gift_listings WHERE unique_gift_id=$1`, uniqueID); err != nil { + return err + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type=$2,owner_peer_id=$3, + last_sale_date=$4,last_sale_currency=$5,last_sale_amount=$6,updated_at=now() WHERE id=$1`, + uniqueID, string(req.To.Type), req.To.ID, req.Date, listingCurrency, listingAmount); err != nil { + return err + } + gift.Owner = req.To + gift.ResellAmount = nil + gift.LastSaleDate = req.Date + gift.LastSaleAmount = &domain.StarGiftAmount{Currency: req.Amount.Currency, Amount: req.Amount.Amount} + saved.Owner = req.To + if req.To.Type == domain.PeerTypeChannel { + saved.MsgID, saved.SavedID = 0, saved.ID + } + result.Saved, result.Unique, result.Balance = saved, gift, balance + send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(gift, messageSenderID, req.To, saved) + return nil + }, + after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + msgID, savedID := sent.RecipientMessage.ID, int64(0) + if req.To.Type == domain.PeerTypeChannel { + msgID, savedID = 0, result.Saved.ID + } + if req.To.Type == domain.PeerTypeUser && msgID <= 0 { + return domain.ErrStarGiftResaleUnavailable + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type=$2,owner_peer_id=$3,from_user_id=$4, + msg_id=$5,saved_id=$6,upgrade_msg_id=$5,gift_date=$7,name_hidden=false,unsaved=false,pinned_order=0,can_transfer_at=0 + WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id, + buyer_peer_type,buyer_peer_id,currency,amount,commission_amount,sold_at,command_key) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, result.Unique.ID, string(seller.Type), seller.ID, + string(req.To.Type), req.To.ID, string(req.Amount.Currency), req.Amount.Amount, commissionAmount, + req.Date, strings.TrimSpace(req.CommandKey)); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_transfer_commands(actor_user_id,command_key,unique_gift_id, + from_peer_type,from_peer_id,to_peer_type,to_peer_id,charge_stars,balance_after,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7,0,$8,$9)`, req.BuyerUserID, strings.TrimSpace(req.CommandKey), + result.Unique.ID, string(seller.Type), seller.ID, string(req.To.Type), req.To.ID, result.Balance.Balance, req.Date); err != nil { + return err + } + if req.To.Type == domain.PeerTypeChannel { + action := domain.ChannelMessageAction{Type: domain.ChannelActionStarGiftUnique, + StarGiftUnique: transferUniqueAction(result.Unique, messageSenderID, req.To, result.Saved)} + if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.BuyerUserID, + result.Saved.ID, req.Date, action); err != nil { + return err + } + } + result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, savedID, msgID, req.Date + result.Saved.FromUserID = messageSenderID + return updateStarGiftResaleProjection(ctx, tx, result.Unique.GiftID) + }, + } + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + return domain.StarGiftTransferResult{}, err + } + result.Send, result.Duplicate = sent, sent.Duplicate + if sent.Duplicate { + return s.loadTransferReplay(ctx, domain.StarGiftTransferRequest{ActorUserID: req.BuyerUserID, CommandKey: req.CommandKey}, sent) + } + return result, nil +} + +func lockSavedStarGiftByUniqueID(ctx context.Context, tx pgx.Tx, uniqueID int64) (domain.SavedStarGift, bool, error) { + row := tx.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, + p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, + p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, + p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, + COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i + JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) + FROM peer_star_gifts p WHERE p.unique_gift_id=$1 FOR UPDATE`, uniqueID) + saved, err := scanSavedStarGift(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SavedStarGift{}, false, nil + } + return saved, err == nil, err +} + +func (s *StarGiftLifecycleStore) refundPendingStarGiftOffers(ctx context.Context, tx pgx.Tx, uniqueID int64, date int, reason string) error { + rows, err := tx.Query(ctx, `SELECT id,buyer_user_id,currency,amount,owner_peer_type,owner_peer_id + FROM star_gift_offers WHERE unique_gift_id=$1 AND status='pending' FOR UPDATE`, uniqueID) + if err != nil { + return err + } + type pending struct { + id, buyer, amount, ownerID int64 + currency, ownerType string + } + items := make([]pending, 0) + for rows.Next() { + var item pending + if err := rows.Scan(&item.id, &item.buyer, &item.currency, &item.amount, &item.ownerType, &item.ownerID); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + rows.Close() + for _, item := range items { + if err := s.creditLifecycleAmount(ctx, tx, item.buyer, domain.StarGiftAmount{Currency: domain.StarGiftCurrency(item.currency), Amount: item.amount}, + domain.StarsReasonGiftOffer, domain.Peer{Type: domain.PeerType(item.ownerType), ID: item.ownerID}, date, "Gift offer refund"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET status='cancelled',resolved_at=$2 WHERE id=$1`, item.id, date); err != nil { + return err + } + } + _ = reason + return nil +} + +func (s *StarGiftLifecycleStore) SendStarGiftOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) { + if s == nil || s.messages == nil || req.BuyerUserID <= 0 || req.Owner.Type != domain.PeerTypeUser || + req.Owner.ID <= 0 || req.Owner.ID == req.BuyerUserID || strings.TrimSpace(req.Slug) == "" || + !req.Price.Valid() || !validStarGiftOfferDuration(req.Duration) || req.RandomID == 0 || req.Date <= 0 { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + if err := s.expireStarGiftOffers(ctx, req.Date); err != nil { + return domain.StarGiftOfferResult{}, err + } + unique, found, err := NewStarGiftStore(s.db).UniqueBySlug(ctx, req.Slug) + if err != nil || !found || unique.Owner != req.Owner || unique.Burned || unique.OwnerAddress != "" || unique.OfferMinStars <= 0 { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + messageReq := domain.SendPrivateTextRequest{ + SenderUserID: req.BuyerUserID, RecipientUserID: req.Owner.ID, RandomID: req.RandomID, Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.BuyerUserID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftOffer, StarGiftOffer: &domain.MessageStarGiftOfferAction{ + Gift: unique, Price: req.Price, ExpiresAt: req.Date + req.Duration, + }, + }}, + } + var result domain.StarGiftOfferResult + hooks := privateSendTxHooks{ + before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { + // Serialize a new pending offer with Craft/transfer/export, all of + // which close market claims before changing the gift lifecycle. + if _, err := tx.Exec(ctx, `SELECT id FROM unique_star_gifts WHERE id=$1 FOR UPDATE`, unique.ID); err != nil { + return err + } + gift, found, err := NewStarGiftStore(tx).UniqueByID(ctx, unique.ID) + if err != nil || !found || gift.Owner != req.Owner || gift.Burned || gift.OwnerAddress != "" || gift.OfferMinStars <= 0 { + return domain.ErrStarGiftOfferInvalid + } + if req.Price.Currency == domain.StarGiftCurrencyStars && gift.OfferMinStars > 0 && req.Price.Amount < int64(gift.OfferMinStars) { + return domain.ErrStarGiftOfferInvalid + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.BuyerUserID, req.Price, domain.StarsReasonGiftOffer, + req.Owner, req.Date, "Collectible gift offer") + if err != nil { + return err + } + var offerID int64 + if err := tx.QueryRow(ctx, `INSERT INTO star_gift_offers(buyer_user_id,owner_peer_type,owner_peer_id, + unique_gift_id,currency,amount,random_id,created_at,expires_at,balance_after) + VALUES($1,'user',$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, req.BuyerUserID, req.Owner.ID, + gift.ID, string(req.Price.Currency), req.Price.Amount, req.RandomID, req.Date, req.Date+req.Duration, balance.Balance).Scan(&offerID); err != nil { + return err + } + result.Offer = domain.StarGiftOffer{ID: offerID, BuyerUserID: req.BuyerUserID, Owner: req.Owner, + UniqueGiftID: gift.ID, Price: req.Price, RandomID: req.RandomID, Status: "pending", + CreatedAt: req.Date, ExpiresAt: req.Date + req.Duration, Gift: gift} + result.Balance = balance + send.Media.ServiceAction.StarGiftOffer.Gift = gift + return nil + }, + after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + ownerMsgID := sent.RecipientMessage.ID + buyerMsgID := sent.SenderMessage.ID + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET offer_msg_id=$2,buyer_msg_id=$3 WHERE id=$1`, result.Offer.ID, ownerMsgID, buyerMsgID); err != nil { + return err + } + result.Offer.OfferMsgID, result.Offer.BuyerMsgID = ownerMsgID, buyerMsgID + return nil + }, + } + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + return domain.StarGiftOfferResult{}, err + } + result.Send, result.Duplicate = sent, sent.Duplicate + if sent.Duplicate { + return s.loadOfferByBuyerRandom(ctx, req.BuyerUserID, req.RandomID, sent) + } + return result, nil +} + +func validStarGiftOfferDuration(duration int) bool { + switch duration { + case 120, 21600, 43200, 86400, 129600, 172800, 259200: + return true + default: + return false + } +} + +func (s *StarGiftLifecycleStore) loadOfferByBuyerRandom(ctx context.Context, buyerUserID, randomID int64, sent domain.SendPrivateTextResult) (domain.StarGiftOfferResult, error) { + offer, err := scanStarGiftOffer(s.db.QueryRow(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id, + currency,amount,random_id,offer_msg_id,buyer_msg_id,status,created_at,expires_at,resolved_at,balance_after + FROM star_gift_offers WHERE buyer_user_id=$1 AND random_id=$2`, buyerUserID, randomID)) + if err != nil { + return domain.StarGiftOfferResult{}, err + } + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, offer.UniqueGiftID) + if err != nil || !found { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + offer.Gift = gift + var balance int64 + if offer.Price.Currency == domain.StarGiftCurrencyTON { + _ = s.db.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id=$1`, buyerUserID).Scan(&balance) + } else { + _ = s.db.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, buyerUserID).Scan(&balance) + } + return domain.StarGiftOfferResult{Offer: offer, Balance: domain.StarsBalance{UserID: buyerUserID, Balance: balance}, Send: sent, Duplicate: true}, nil +} + +func scanStarGiftOffer(row rowScanner) (domain.StarGiftOffer, error) { + var offer domain.StarGiftOffer + var ownerType, currency string + if err := row.Scan(&offer.ID, &offer.BuyerUserID, &ownerType, &offer.Owner.ID, &offer.UniqueGiftID, + ¤cy, &offer.Price.Amount, &offer.RandomID, &offer.OfferMsgID, &offer.BuyerMsgID, + &offer.Status, &offer.CreatedAt, &offer.ExpiresAt, &offer.ResolvedAt, new(int64)); err != nil { + return domain.StarGiftOffer{}, err + } + offer.Owner.Type = domain.PeerType(ownerType) + offer.Price.Currency = domain.StarGiftCurrency(currency) + return offer, nil +} + +func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) { + if s == nil || s.messages == nil || req.OwnerUserID <= 0 || req.OfferMsgID <= 0 || req.Date <= 0 { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + if err := s.expireStarGiftOffers(ctx, req.Date); err != nil { + return domain.StarGiftOfferResult{}, err + } + offer, err := scanStarGiftOffer(s.db.QueryRow(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id, + currency,amount,random_id,offer_msg_id,buyer_msg_id,status,created_at,expires_at,resolved_at,balance_after + FROM star_gift_offers WHERE owner_peer_type='user' AND owner_peer_id=$1 AND offer_msg_id=$2`, req.OwnerUserID, req.OfferMsgID)) + if err != nil || offer.Status != "pending" || offer.ExpiresAt <= req.Date { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferExpired + } + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, offer.UniqueGiftID) + if err != nil || !found { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + offer.Gift = gift + actionKind := domain.MessageServiceActionStarGiftUnique + action := &domain.MessageServiceAction{Kind: actionKind, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ + Gift: gift, FromUserID: req.OwnerUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: offer.BuyerUserID}, + Transferred: true, FromOffer: true, Saved: true, + }} + if req.Decline { + action = &domain.MessageServiceAction{Kind: domain.MessageServiceActionStarGiftOfferDeclined, + StarGiftOfferDeclined: &domain.MessageStarGiftOfferDeclinedAction{Gift: gift, Price: offer.Price}} + } + messageReq := domain.SendPrivateTextRequest{SenderUserID: req.OwnerUserID, RecipientUserID: offer.BuyerUserID, + RandomID: lifecycleCommandRandomID("resolve-offer", offer.ID, req.Decline), Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.OwnerUserID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}} + var result domain.StarGiftOfferResult + var commissionAmount int64 + hooks := privateSendTxHooks{ + before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { + locked, err := scanStarGiftOffer(tx.QueryRow(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id, + currency,amount,random_id,offer_msg_id,buyer_msg_id,status,created_at,expires_at,resolved_at,balance_after + FROM star_gift_offers WHERE id=$1 FOR UPDATE`, offer.ID)) + if err != nil || locked.Status != "pending" || locked.ExpiresAt <= req.Date { + return domain.ErrStarGiftOfferExpired + } + locked.Gift = gift + if req.Decline { + if err := s.creditLifecycleAmount(ctx, tx, locked.BuyerUserID, locked.Price, domain.StarsReasonGiftOffer, + locked.Owner, req.Date, "Gift offer refund"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET status='declined',resolved_at=$2,resolution_notified=true WHERE id=$1`, locked.ID, req.Date); err != nil { + return err + } + locked.Status, locked.ResolvedAt = "declined", req.Date + result.Offer = locked + return nil + } + saved, found, err := lockSavedStarGiftByUniqueID(ctx, tx, locked.UniqueGiftID) + if err != nil || !found || saved.Owner != locked.Owner || !saved.LifecycleStatus.Live() { + return domain.ErrStarGiftOfferInvalid + } + current, found, err := NewStarGiftStore(tx).UniqueByID(ctx, locked.UniqueGiftID) + if err != nil || !found || current.Owner != locked.Owner || current.Burned || current.OwnerAddress != "" { + return domain.ErrStarGiftOfferInvalid + } + if _, commission, err := s.creditPeerLifecycleAmount(ctx, tx, locked.Owner, req.OwnerUserID, locked.Price, + domain.StarsReasonGiftOffer, domain.Peer{Type: domain.PeerTypeUser, ID: locked.BuyerUserID}, + current.ID, req.Date, "Accepted gift offer"); err != nil { + return err + } else { + commissionAmount = commission + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM star_gift_listings WHERE unique_gift_id=$1`, current.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type='user',owner_peer_id=$2, + last_sale_date=$3,last_sale_currency=$4,last_sale_amount=$5,updated_at=now() WHERE id=$1`, current.ID, + locked.BuyerUserID, req.Date, string(locked.Price.Currency), locked.Price.Amount); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET status='accepted',resolved_at=$2,resolution_notified=true WHERE id=$1`, locked.ID, req.Date); err != nil { + return err + } + if err := s.refundPendingStarGiftOffersExcept(ctx, tx, current.ID, locked.ID, req.Date); err != nil { + return err + } + current.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: locked.BuyerUserID} + current.ResellAmount = nil + current.LastSaleDate = req.Date + current.LastSaleAmount = &locked.Price + locked.Status, locked.ResolvedAt, locked.Gift = "accepted", req.Date, current + result.Offer = locked + result.Unique = current + result.Saved = saved + send.Media.ServiceAction.StarGiftUnique.Gift = current + return nil + }, + after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + if req.Decline { + return nil + } + msgID := sent.RecipientMessage.ID + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type='user',owner_peer_id=$2,from_user_id=$3, + msg_id=$4,saved_id=0,upgrade_msg_id=$4,gift_date=$5,name_hidden=false,unsaved=false,pinned_order=0,can_transfer_at=0 + WHERE id=$1`, result.Saved.ID, result.Offer.BuyerUserID, req.OwnerUserID, msgID, req.Date); err != nil { + return err + } + result.Saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: result.Offer.BuyerUserID} + result.Saved.FromUserID, result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = req.OwnerUserID, msgID, 0, msgID, req.Date + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id, + buyer_peer_type,buyer_peer_id,currency,amount,commission_amount,sold_at,command_key) + VALUES($1,'user',$2,'user',$3,$4,$5,$6,$7,$8)`, result.Offer.UniqueGiftID, req.OwnerUserID, + result.Offer.BuyerUserID, string(result.Offer.Price.Currency), result.Offer.Price.Amount, commissionAmount, + req.Date, fmt.Sprintf("offer:%d", result.Offer.ID)); err != nil { + return err + } + return updateStarGiftResaleProjection(ctx, tx, result.Offer.Gift.GiftID) + }, + } + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + return domain.StarGiftOfferResult{}, err + } + if sent.Duplicate { + reloaded, loadErr := s.loadOfferByBuyerRandom(ctx, offer.BuyerUserID, offer.RandomID, sent) + if loadErr != nil { + return domain.StarGiftOfferResult{}, loadErr + } + reloaded.Duplicate = true + return reloaded, nil + } + result.Send = sent + return result, nil +} + +func (s *StarGiftLifecycleStore) expireStarGiftOffers(ctx context.Context, now int) error { + if now <= 0 || s.messages == nil { + return nil + } + if _, err := s.expireStarGiftOffersBatch(ctx, now, 100); err != nil { + return err + } + _, err := s.dispatchStarGiftOfferResolutions(ctx, 100) + return err +} + +func (s *StarGiftLifecycleStore) expireStarGiftOffersBatch(ctx context.Context, now, limit int) (int, error) { + if now <= 0 || limit <= 0 { + return 0, nil + } + processed := 0 + err := withTx(ctx, s.db, "expire star gift offers", func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id,currency,amount +FROM star_gift_offers WHERE status='pending' AND expires_at<=$1 ORDER BY expires_at,id LIMIT $2 FOR UPDATE SKIP LOCKED`, now, limit) + if err != nil { + return err + } + type expired struct { + id, buyer, ownerID, uniqueID, amount int64 + ownerType, currency string + } + items := make([]expired, 0) + for rows.Next() { + var item expired + if err := rows.Scan(&item.id, &item.buyer, &item.ownerType, &item.ownerID, &item.uniqueID, &item.currency, &item.amount); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + for _, item := range items { + owner := domain.Peer{Type: domain.PeerType(item.ownerType), ID: item.ownerID} + if err := s.creditLifecycleAmount(ctx, tx, item.buyer, + domain.StarGiftAmount{Currency: domain.StarGiftCurrency(item.currency), Amount: item.amount}, + domain.StarsReasonGiftOffer, owner, now, "Expired gift offer refund"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET status='expired',resolved_at=$2 WHERE id=$1`, item.id, now); err != nil { + return err + } + } + processed = len(items) + return nil + }) + return processed, err +} + +func (s *StarGiftLifecycleStore) dispatchStarGiftOfferResolutions(ctx context.Context, limit int) (int, error) { + if limit <= 0 || s.messages == nil { + return 0, nil + } + rows, err := s.db.Query(ctx, `SELECT id,buyer_user_id,owner_peer_id,unique_gift_id,currency,amount,resolved_at,status +FROM star_gift_offers WHERE status IN ('expired','cancelled') AND NOT resolution_notified ORDER BY id LIMIT $1`, limit) + if err != nil { + return 0, err + } + type notice struct { + id, buyer, owner, uniqueID, amount int64 + currency, status string + date int + } + items := make([]notice, 0) + for rows.Next() { + var item notice + if err := rows.Scan(&item.id, &item.buyer, &item.owner, &item.uniqueID, &item.currency, &item.amount, &item.date, &item.status); err != nil { + rows.Close() + return 0, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + rows.Close() + return 0, err + } + rows.Close() + for _, item := range items { + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, item.uniqueID) + if err != nil || !found { + return 0, domain.ErrStarGiftOfferInvalid + } + _, err = s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: item.owner, RecipientUserID: item.buyer, + RandomID: lifecycleCommandRandomID("resolve-offer-outbox", item.id, item.status), Date: item.date, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftOfferDeclined, StarGiftOfferDeclined: &domain.MessageStarGiftOfferDeclinedAction{ + Gift: gift, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrency(item.currency), Amount: item.amount}, Expired: item.status == "expired"}}}}) + if err != nil { + return 0, err + } + if _, err := s.db.Exec(ctx, `UPDATE star_gift_offers SET resolution_notified=true WHERE id=$1 AND status=$2`, item.id, item.status); err != nil { + return 0, err + } + } + return len(items), nil +} + +func (s *StarGiftLifecycleStore) refundPendingStarGiftOffersExcept(ctx context.Context, tx pgx.Tx, uniqueID, exceptID int64, date int) error { + rows, err := tx.Query(ctx, `SELECT id,buyer_user_id,currency,amount,owner_peer_type,owner_peer_id + FROM star_gift_offers WHERE unique_gift_id=$1 AND status='pending' AND id<>$2 FOR UPDATE`, uniqueID, exceptID) + if err != nil { + return err + } + type item struct { + id, buyer, amount, ownerID int64 + currency, ownerType string + } + items := make([]item, 0) + for rows.Next() { + var v item + if err := rows.Scan(&v.id, &v.buyer, &v.currency, &v.amount, &v.ownerType, &v.ownerID); err != nil { + rows.Close() + return err + } + items = append(items, v) + } + rows.Close() + for _, v := range items { + if err := s.creditLifecycleAmount(ctx, tx, v.buyer, domain.StarGiftAmount{Currency: domain.StarGiftCurrency(v.currency), Amount: v.amount}, + domain.StarsReasonGiftOffer, domain.Peer{Type: domain.PeerType(v.ownerType), ID: v.ownerID}, date, "Gift offer refund"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET status='cancelled',resolved_at=$2 WHERE id=$1`, v.id, date); err != nil { + return err + } + } + return nil +} + +func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) { + var result domain.StarGiftTransferResult + err := withTx(ctx, s.db, "transfer star gift to channel", func(tx pgx.Tx) error { + saved, unique, err := lockTransferableStarGift(ctx, tx, req.ActorUserID, req.Ref, req.Date) + if err != nil { + return err + } + if saved.TransferStars != req.ChargeStars { + return domain.ErrStarGiftTransferUnavailable + } + if err := ensureNoStarGiftMarketConflict(ctx, tx, unique.ID); err != nil { + return err + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.ActorUserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars}, + domain.StarsReasonGiftTransfer, req.To, req.Date, "Star gift transfer") + if err != nil { + return err + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type='channel',owner_peer_id=$2,updated_at=now() WHERE id=$1`, unique.ID, req.To.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type='channel',owner_peer_id=$2,from_user_id=$3, + msg_id=0,saved_id=id,upgrade_msg_id=0,gift_date=$4,name_hidden=false,unsaved=false,pinned_order=0,can_transfer_at=0 WHERE id=$1`, + saved.ID, req.To.ID, req.ActorUserID, req.Date); err != nil { + return err + } + unique.Owner = req.To + saved.Owner, saved.MsgID, saved.SavedID, saved.UpgradeMsgID, saved.Date = req.To, 0, saved.ID, 0, req.Date + saved.FromUserID = req.ActorUserID + action := domain.ChannelMessageAction{Type: domain.ChannelActionStarGiftUnique, + StarGiftUnique: transferUniqueAction(unique, req.ActorUserID, req.To, saved)} + if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.ActorUserID, saved.ID, req.Date, action); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_transfer_commands(actor_user_id,command_key,unique_gift_id, + from_peer_type,from_peer_id,to_peer_type,to_peer_id,charge_stars,balance_after,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.ActorUserID, strings.TrimSpace(req.CommandKey), unique.ID, + string(req.Ref.Owner.Type), req.Ref.Owner.ID, string(req.To.Type), req.To.ID, req.ChargeStars, balance.Balance, req.Date); err != nil { + return err + } + result.Saved, result.Unique, result.Balance = saved, unique, balance + return nil + }) + return result, err +} + +func lockTransferableStarGift(ctx context.Context, tx pgx.Tx, actorUserID int64, ref domain.SavedStarGiftRef, now int) (domain.SavedStarGift, domain.UniqueStarGift, error) { + saved, unique, err := lockOwnedUniqueStarGift(ctx, tx, actorUserID, ref) + if err != nil || saved.CanTransferAt > now { + return domain.SavedStarGift{}, domain.UniqueStarGift{}, domain.ErrStarGiftTransferUnavailable + } + return saved, unique, nil +} + +// lockOwnedUniqueStarGift locks the live ownership aggregate without applying a +// transfer cooldown. Independent capabilities such as dropping original details +// must not be accidentally blocked by can_transfer_at. +func lockOwnedUniqueStarGift(ctx context.Context, tx pgx.Tx, actorUserID int64, ref domain.SavedStarGiftRef) (domain.SavedStarGift, domain.UniqueStarGift, error) { + saved, err := lockSavedStarGiftForUpgrade(ctx, tx, ref) + if err != nil { + return domain.SavedStarGift{}, domain.UniqueStarGift{}, err + } + if !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.Owner != ref.Owner || + saved.Owner.Type == domain.PeerTypeUser && saved.Owner.ID != actorUserID { + return domain.SavedStarGift{}, domain.UniqueStarGift{}, domain.ErrStarGiftTransferUnavailable + } + unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID) + if err != nil { + return domain.SavedStarGift{}, domain.UniqueStarGift{}, err + } + if !found || unique.Burned || unique.OwnerAddress != "" || unique.Owner != saved.Owner { + return domain.SavedStarGift{}, domain.UniqueStarGift{}, domain.ErrStarGiftTransferUnavailable + } + return saved, unique, nil +} + +func ensureNoStarGiftMarketConflict(ctx context.Context, tx pgx.Tx, uniqueID int64) error { + var listing, offers bool + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM star_gift_listings WHERE unique_gift_id=$1), + EXISTS(SELECT 1 FROM star_gift_offers WHERE unique_gift_id=$1 AND status='pending')`, uniqueID).Scan(&listing, &offers); err != nil { + return err + } + if listing || offers { + return domain.ErrStarGiftTransferUnavailable + } + return nil +} + +func transferUniqueAction(unique domain.UniqueStarGift, fromUserID int64, to domain.Peer, saved domain.SavedStarGift) *domain.MessageStarGiftUniqueAction { + return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: to, + SavedID: saved.SavedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt, + TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt, + DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt} +} + +func (s *StarGiftLifecycleStore) debitLifecycleAmount(ctx context.Context, tx pgx.Tx, userID int64, amount domain.StarGiftAmount, + reason domain.StarsTransactionReason, peer domain.Peer, date int, title string) (domain.StarsBalance, error) { + if amount.Amount == 0 { + var balance domain.StarsBalance + balance.UserID = userID + err := tx.QueryRow(ctx, `SELECT balance,granted FROM stars_balances WHERE user_id=$1`, userID).Scan(&balance.Balance, &balance.Granted) + if errors.Is(err, pgx.ErrNoRows) { + return balance, nil + } + return balance, err + } + if amount.Currency == domain.StarGiftCurrencyTON { + if _, err := s.ensureTonGrantTx(ctx, tx, userID, date); err != nil { + return domain.StarsBalance{}, err + } + var balance int64 + if err := tx.QueryRow(ctx, `UPDATE ton_balances SET balance_nanoton=balance_nanoton-$2,updated_at=now() + WHERE user_id=$1 AND balance_nanoton>=$2 RETURNING balance_nanoton`, userID, amount.Amount).Scan(&balance); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarsBalance{}, domain.ErrStarsInsufficient + } + return domain.StarsBalance{}, err + } + _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) + VALUES($1,$2,$3,$4,$5,$6)`, userID, -amount.Amount, string(reason), nullableStarGiftPeerType(peer), nullableStarGiftPeerID(peer), date) + return domain.StarsBalance{UserID: userID, Balance: balance}, err + } + result := domain.StarsBalance{UserID: userID} + var current int64 + if err := tx.QueryRow(ctx, `SELECT balance,granted FROM stars_balances WHERE user_id=$1 FOR UPDATE`, userID).Scan(¤t, &result.Granted); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarsBalance{}, domain.ErrStarsInsufficient + } + return domain.StarsBalance{}, err + } + if current < amount.Amount { + return domain.StarsBalance{}, domain.ErrStarsInsufficient + } + if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance=balance-$2,updated_at=now() WHERE user_id=$1 RETURNING balance`, userID, amount.Amount).Scan(&result.Balance); err != nil { + return domain.StarsBalance{}, err + } + if err := insertStarsTxn(ctx, tx, userID, -amount.Amount, reason, peer, date, title, ""); err != nil { + return domain.StarsBalance{}, err + } + return result, nil +} + +func (s *StarGiftLifecycleStore) creditLifecycleAmount(ctx context.Context, tx pgx.Tx, userID int64, amount domain.StarGiftAmount, + reason domain.StarsTransactionReason, peer domain.Peer, date int, title string) error { + if amount.Currency == domain.StarGiftCurrencyTON { + if _, err := tx.Exec(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,$2,false) + ON CONFLICT(user_id) DO UPDATE SET balance_nanoton=ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now()`, userID, amount.Amount); err != nil { + return err + } + _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) + VALUES($1,$2,$3,$4,$5,$6)`, userID, amount.Amount, string(reason), nullableStarGiftPeerType(peer), nullableStarGiftPeerID(peer), date) + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO stars_balances(user_id,balance,updated_at) VALUES($1,$2,now()) + ON CONFLICT(user_id) DO UPDATE SET balance=stars_balances.balance+EXCLUDED.balance,updated_at=now()`, userID, amount.Amount); err != nil { + return err + } + return insertStarsTxn(ctx, tx, userID, amount.Amount, reason, peer, date, title, "") +} + +// creditPeerLifecycleAmount credits marketplace proceeds to the actual gift +// owner. Channel Stars and TON are isolated local revenue ledgers; neither is +// redirected to the administrator who happened to execute the RPC. +func (s *StarGiftLifecycleStore) creditPeerLifecycleAmount(ctx context.Context, tx pgx.Tx, owner domain.Peer, + actorUserID int64, amount domain.StarGiftAmount, reason domain.StarsTransactionReason, counterparty domain.Peer, + giftID int64, date int, title string) (int64, int64, error) { + if !validLifecyclePeer(owner) || actorUserID <= 0 || !amount.Valid() || giftID <= 0 || date <= 0 { + return 0, 0, domain.ErrStarGiftResaleUnavailable + } + permille := s.market.StarsProceedsPermille + if amount.Currency == domain.StarGiftCurrencyTON { + permille = s.market.TONProceedsPermille + } + proceeds := amount.Amount/1000*int64(permille) + amount.Amount%1000*int64(permille)/1000 + commission := amount.Amount - proceeds + credited := amount + credited.Amount = proceeds + if owner.Type == domain.PeerTypeUser { + if proceeds > 0 { + if err := s.creditLifecycleAmount(ctx, tx, owner.ID, credited, reason, counterparty, date, title); err != nil { + return 0, 0, err + } + } + var balance int64 + if amount.Currency == domain.StarGiftCurrencyTON { + if err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance_nanoton FROM ton_balances WHERE user_id=$1),0)`, owner.ID).Scan(&balance); err != nil { + return 0, 0, err + } + } else if err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM stars_balances WHERE user_id=$1),0)`, owner.ID).Scan(&balance); err != nil { + return 0, 0, err + } + return balance, commission, nil + } + + var balance int64 + if amount.Currency == domain.StarGiftCurrencyTON { + if proceeds == 0 { + err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1),0)`, owner.ID).Scan(&balance) + return balance, commission, err + } + if err := tx.QueryRow(ctx, `INSERT INTO channel_ton_balances(channel_id,balance_nanoton) VALUES($1,$2) + ON CONFLICT(channel_id) DO UPDATE SET balance_nanoton=channel_ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now() + RETURNING balance_nanoton`, owner.ID, proceeds).Scan(&balance); err != nil { + return 0, 0, err + } + if _, err := tx.Exec(ctx, `INSERT INTO channel_ton_transactions + (channel_id,actor_user_id,amount_nanoton,reason,peer_type,peer_id,gift_id,date) + VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, owner.ID, actorUserID, proceeds, string(reason), + string(counterparty.Type), counterparty.ID, giftID, date); err != nil { + return 0, 0, err + } + return balance, commission, nil + } + if proceeds == 0 { + err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, owner.ID).Scan(&balance) + return balance, commission, err + } + if err := tx.QueryRow(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,$2) + ON CONFLICT(channel_id) DO UPDATE SET balance=channel_stars_balances.balance+EXCLUDED.balance,updated_at=now() + RETURNING balance`, owner.ID, proceeds).Scan(&balance); err != nil { + return 0, 0, err + } + if _, err := tx.Exec(ctx, `INSERT INTO channel_stars_transactions + (channel_id,actor_user_id,amount,reason,peer_type,peer_id,gift_id,date) + VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, owner.ID, actorUserID, proceeds, string(reason), + string(counterparty.Type), counterparty.ID, giftID, date); err != nil { + return 0, 0, err + } + return balance, commission, nil +} + +func (s *StarGiftLifecycleStore) loadTransferReplay(ctx context.Context, req domain.StarGiftTransferRequest, sent domain.SendPrivateTextResult) (domain.StarGiftTransferResult, error) { + var uniqueID, balance int64 + if err := s.db.QueryRow(ctx, `SELECT unique_gift_id,balance_after FROM star_gift_transfer_commands WHERE actor_user_id=$1 AND command_key=$2`, + req.ActorUserID, strings.TrimSpace(req.CommandKey)).Scan(&uniqueID, &balance); err != nil { + return domain.StarGiftTransferResult{}, err + } + unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) + if err != nil || !found { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable + } + saved, found, err := savedStarGiftByUniqueID(ctx, s.db, uniqueID) + if err != nil || !found { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable + } + uniqueCopy := unique + saved.Unique = &uniqueCopy + return domain.StarGiftTransferResult{Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.ActorUserID, Balance: balance}, Send: sent, Duplicate: true}, nil +} + +func savedStarGiftByUniqueID(ctx context.Context, db sqlcgen.DBTX, uniqueID int64) (domain.SavedStarGift, bool, error) { + row := db.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, + p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, + p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, + p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, + COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i + JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) + FROM peer_star_gifts p WHERE p.unique_gift_id=$1`, uniqueID) + saved, err := scanSavedStarGift(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SavedStarGift{}, false, nil + } + return saved, err == nil, err +} + +func updateStarGiftResaleProjection(ctx context.Context, tx pgx.Tx, giftID int64) error { + _, err := tx.Exec(ctx, `UPDATE star_gift_catalog c SET + availability_resale=(SELECT COUNT(*) FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id WHERE u.gift_id=c.gift_id), + resell_min_stars=COALESCE((SELECT MIN(l.amount) FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id WHERE u.gift_id=c.gift_id AND l.currency='XTR'),0), + updated_at=now() WHERE c.gift_id=$1`, giftID) + return err +} + +func (s *StarGiftLifecycleStore) SetStarGiftNotifications(ctx context.Context, userID, channelID int64, enabled bool) error { + if userID <= 0 || channelID <= 0 { + return domain.ErrStarGiftOwnerInvalid + } + _, err := s.db.Exec(ctx, `INSERT INTO star_gift_notification_settings(user_id,channel_id,enabled) VALUES($1,$2,$3) +ON CONFLICT(user_id,channel_id) DO UPDATE SET enabled=EXCLUDED.enabled,updated_at=now()`, userID, channelID, enabled) + return err +} + +func (s *StarGiftLifecycleStore) RecordStarGiftWithdrawal(ctx context.Context, req domain.StarGiftWithdrawalRequest, provider, providerRequestID, url string, expiresAt int) (domain.StarGiftWithdrawal, error) { + if req.UserID <= 0 || !req.Ref.Valid() || req.Date <= 0 || expiresAt <= req.Date || strings.TrimSpace(provider) == "" || strings.TrimSpace(providerRequestID) == "" || strings.TrimSpace(url) == "" { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + err := withTx(ctx, s.db, "record star gift withdrawal", func(tx pgx.Tx) error { + saved, err := lockSavedStarGiftForUpgrade(ctx, tx, req.Ref) + if err != nil { + return err + } + if saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanExportAt > req.Date { + return domain.ErrStarGiftTransferUnavailable + } + var existingID int64 + var existingStatus string + var existingExpires int + err = tx.QueryRow(ctx, `SELECT id,status,expires_at FROM star_gift_withdrawal_requests WHERE unique_gift_id=$1 FOR UPDATE`, saved.UniqueGiftID). + Scan(&existingID, &existingStatus, &existingExpires) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + if existingStatus == "completed" || existingStatus == "pending" && existingExpires > req.Date { + return nil + } + _, err = tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests SET provider=$2,provider_request_id=$3,url=$4, +status='pending',created_at=$5,expires_at=$6,completed_at=0 WHERE id=$1`, existingID, provider, providerRequestID, url, req.Date, expiresAt) + return err + } + _, err = tx.Exec(ctx, `INSERT INTO star_gift_withdrawal_requests(unique_gift_id,owner_user_id,provider,provider_request_id,url,created_at,expires_at) +VALUES($1,$2,$3,$4,$5,$6,$7)`, saved.UniqueGiftID, req.UserID, provider, providerRequestID, url, req.Date, expiresAt) + return err + }) + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + // If an unexpired request already existed, return it instead of exposing a + // newly generated but unpersisted bearer URL. + saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref) + if err != nil || !found { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + return s.resolveStarGiftWithdrawalByUniqueID(ctx, saved.UniqueGiftID) +} + +func (s *StarGiftLifecycleStore) ResolveStarGiftWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) { + providerRequestID = strings.TrimSpace(providerRequestID) + if providerRequestID == "" || len(providerRequestID) > 256 { + return domain.StarGiftWithdrawal{}, false, nil + } + var uniqueID int64 + if err := s.db.QueryRow(ctx, `SELECT unique_gift_id FROM star_gift_withdrawal_requests WHERE provider_request_id=$1`, providerRequestID).Scan(&uniqueID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftWithdrawal{}, false, nil + } + return domain.StarGiftWithdrawal{}, false, err + } + withdrawal, err := s.resolveStarGiftWithdrawalByUniqueID(ctx, uniqueID) + return withdrawal, err == nil, err +} + +func (s *StarGiftLifecycleStore) resolveStarGiftWithdrawalByUniqueID(ctx context.Context, uniqueID int64) (domain.StarGiftWithdrawal, error) { + var out domain.StarGiftWithdrawal + if err := s.db.QueryRow(ctx, `SELECT provider_request_id,url,expires_at,status FROM star_gift_withdrawal_requests WHERE unique_gift_id=$1`, uniqueID). + Scan(&out.ProviderRequestID, &out.URL, &out.ExpiresAt, &out.Status); err != nil { + return domain.StarGiftWithdrawal{}, err + } + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) + if err != nil || !found { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + out.Gift = gift + return out, nil +} + +func (s *StarGiftLifecycleStore) CompleteStarGiftWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) { + providerRequestID = strings.TrimSpace(providerRequestID) + if providerRequestID == "" || len(providerRequestID) > 256 || date <= 0 { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + expired := false + err := withTx(ctx, s.db, "complete star gift withdrawal", func(tx pgx.Tx) error { + var uniqueID, ownerUserID int64 + var status string + var expiresAt int + if err := tx.QueryRow(ctx, `SELECT unique_gift_id,owner_user_id,status,expires_at FROM star_gift_withdrawal_requests +WHERE provider_request_id=$1 FOR UPDATE`, providerRequestID).Scan(&uniqueID, &ownerUserID, &status, &expiresAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrStarGiftWithdrawalUnavailable + } + return err + } + if status == "completed" { + return nil + } + if status != "pending" || expiresAt <= date { + expired = true + _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests SET status='failed',completed_at=$2 WHERE provider_request_id=$1`, providerRequestID, date) + return err + } + saved, found, err := lockSavedStarGiftByUniqueID(ctx, tx, uniqueID) + if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: ownerUserID}) || !saved.LifecycleStatus.Live() { + return domain.ErrStarGiftWithdrawalUnavailable + } + unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID) + if err != nil || !found || unique.Owner != saved.Owner || unique.Burned || unique.OwnerAddress != "" { + return domain.ErrStarGiftWithdrawalUnavailable + } + if err := s.refundPendingStarGiftOffers(ctx, tx, uniqueID, date, "gift exported"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM star_gift_listings WHERE unique_gift_id=$1`, uniqueID); err != nil { + return err + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + ownerAddress := "telesrv-owner:" + providerRequestID + requestHash := sha256.Sum256([]byte(providerRequestID)) + giftAddress := fmt.Sprintf("telesrv-gift:%s:%x", unique.Slug, requestHash[:8]) + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type=NULL,owner_peer_id=NULL, +owner_address=$2,gift_address=$3,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0 WHERE id=$1`, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests SET status='completed',completed_at=$2 WHERE provider_request_id=$1`, providerRequestID, date); err != nil { + return err + } + return updateStarGiftResaleProjection(ctx, tx, unique.GiftID) + }) + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + if expired { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + withdrawal, found, err := s.ResolveStarGiftWithdrawal(ctx, providerRequestID) + if err != nil || !found { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + return withdrawal, nil +} + +func (s *StarGiftLifecycleStore) TonBalance(ctx context.Context, userID int64) (int64, error) { + if userID <= 0 { + return 0, domain.ErrStarGiftOwnerInvalid + } + var balance int64 + err := withTx(ctx, s.db, "ensure internal ton grant", func(tx pgx.Tx) error { + var err error + balance, err = s.ensureTonGrantTx(ctx, tx, userID, int(time.Now().Unix())) + return err + }) + return balance, err +} + +func (s *StarGiftLifecycleStore) ensureTonGrantTx(ctx context.Context, tx pgx.Tx, userID int64, date int) (int64, error) { + if _, err := tx.Exec(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,0,false) +ON CONFLICT(user_id) DO NOTHING`, userID); err != nil { + return 0, err + } + var balance int64 + var granted bool + if err := tx.QueryRow(ctx, `SELECT balance_nanoton,granted FROM ton_balances WHERE user_id=$1 FOR UPDATE`, userID). + Scan(&balance, &granted); err != nil { + return 0, err + } + if granted { + return balance, nil + } + if err := tx.QueryRow(ctx, `UPDATE ton_balances SET balance_nanoton=balance_nanoton+$2,granted=true,updated_at=now() +WHERE user_id=$1 RETURNING balance_nanoton`, userID, s.tonStartingGrant).Scan(&balance); err != nil { + return 0, err + } + if s.tonStartingGrant > 0 { + if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,date) +VALUES($1,$2,$3,$4)`, userID, s.tonStartingGrant, string(domain.StarsReasonGrant), date); err != nil { + return 0, err + } + } + return balance, nil +} + +func (s *StarGiftLifecycleStore) TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) { + if userID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes { + return domain.TonTransactionPage{}, domain.ErrStarGiftOwnerInvalid + } + if _, err := s.TonBalance(ctx, userID); err != nil { + return domain.TonTransactionPage{}, err + } + cursor, hasCursor := domain.DecodeStarsCursor(offset) + args := []any{userID, limit + 1} + where := "user_id=$1" + if hasCursor { + where += " AND id<$3" + args = append(args, cursor) + } + rows, err := s.db.Query(ctx, `SELECT id,user_id,COALESCE(peer_type,''),COALESCE(peer_id,0),COALESCE(gift_id,0), +amount_nanoton,date,reason FROM ton_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...) + if err != nil { + return domain.TonTransactionPage{}, err + } + defer rows.Close() + items := make([]domain.TonTransaction, 0, limit+1) + for rows.Next() { + var item domain.TonTransaction + var peerType string + if err := rows.Scan(&item.ID, &item.UserID, &peerType, &item.Peer.ID, &item.GiftID, &item.Amount, &item.Date, &item.Reason); err != nil { + return domain.TonTransactionPage{}, err + } + item.Peer.Type = domain.PeerType(peerType) + items = append(items, item) + } + if err := rows.Err(); err != nil { + return domain.TonTransactionPage{}, err + } + page := domain.TonTransactionPage{} + if len(items) > limit { + items = items[:limit] + page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID) + } + page.Transactions = items + if err := s.db.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id=$1`, userID).Scan(&page.Balance); err != nil { + return domain.TonTransactionPage{}, err + } + return page, nil +} + +// Channel Stars/TON ledgers are revenue projections owned by the channel. They +// never receive a starting grant and are deliberately separate from the actor +// administrator's personal balances. +func (s *StarGiftLifecycleStore) ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) { + if channelID <= 0 { + return 0, domain.ErrStarGiftOwnerInvalid + } + var balance int64 + err := s.db.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, channelID).Scan(&balance) + return balance, err +} + +func (s *StarGiftLifecycleStore) ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) { + if channelID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes { + return domain.StarsTransactionPage{}, domain.ErrStarGiftOwnerInvalid + } + cursor, hasCursor := domain.DecodeStarsCursor(offset) + args := []any{channelID, limit + 1} + where := "channel_id=$1" + if hasCursor { + where += " AND id<$3" + args = append(args, cursor) + } + rows, err := s.db.Query(ctx, `SELECT id,COALESCE(peer_type,''),COALESCE(peer_id,0),amount,date,reason +FROM channel_stars_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...) + if err != nil { + return domain.StarsTransactionPage{}, err + } + defer rows.Close() + items := make([]domain.StarsTransaction, 0, limit+1) + for rows.Next() { + var item domain.StarsTransaction + var peerType string + if err := rows.Scan(&item.ID, &peerType, &item.Peer.ID, &item.Amount, &item.Date, &item.Reason); err != nil { + return domain.StarsTransactionPage{}, err + } + item.Peer.Type = domain.PeerType(peerType) + items = append(items, item) + } + if err := rows.Err(); err != nil { + return domain.StarsTransactionPage{}, err + } + page := domain.StarsTransactionPage{} + if len(items) > limit { + items = items[:limit] + page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID) + } + page.Transactions = items + page.Balance, err = s.ChannelStarsBalance(ctx, channelID) + return page, err +} + +func (s *StarGiftLifecycleStore) ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) { + if channelID <= 0 { + return 0, domain.ErrStarGiftOwnerInvalid + } + var balance int64 + err := s.db.QueryRow(ctx, `SELECT COALESCE((SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1),0)`, channelID).Scan(&balance) + return balance, err +} + +func (s *StarGiftLifecycleStore) ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) { + if channelID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes { + return domain.TonTransactionPage{}, domain.ErrStarGiftOwnerInvalid + } + cursor, hasCursor := domain.DecodeStarsCursor(offset) + args := []any{channelID, limit + 1} + where := "channel_id=$1" + if hasCursor { + where += " AND id<$3" + args = append(args, cursor) + } + rows, err := s.db.Query(ctx, `SELECT id,COALESCE(peer_type,''),COALESCE(peer_id,0),COALESCE(gift_id,0),amount_nanoton,date,reason +FROM channel_ton_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...) + if err != nil { + return domain.TonTransactionPage{}, err + } + defer rows.Close() + items := make([]domain.TonTransaction, 0, limit+1) + for rows.Next() { + var item domain.TonTransaction + var peerType string + if err := rows.Scan(&item.ID, &peerType, &item.Peer.ID, &item.GiftID, &item.Amount, &item.Date, &item.Reason); err != nil { + return domain.TonTransactionPage{}, err + } + item.Peer.Type = domain.PeerType(peerType) + items = append(items, item) + } + if err := rows.Err(); err != nil { + return domain.TonTransactionPage{}, err + } + page := domain.TonTransactionPage{} + if len(items) > limit { + items = items[:limit] + page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID) + } + page.Transactions = items + page.Balance, err = s.ChannelTonBalance(ctx, channelID) + return page, err +} + +func lifecycleCommandRandomID(parts ...any) int64 { + sum := sha256.Sum256([]byte(fmt.Sprint(parts...))) + id := int64(binary.LittleEndian.Uint64(sum[:8]) & 0x7fffffffffffffff) + if id == 0 { + return 1 + } + return id +} + +func validLifecyclePeer(peer domain.Peer) bool { + return peer.ID > 0 && (peer.Type == domain.PeerTypeUser || peer.Type == domain.PeerTypeChannel) +} + +func sortedUniqueInt64(values []int64) []int64 { + out := append([]int64(nil), values...) + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +var _ store.StarGiftLifecycleStore = (*StarGiftLifecycleStore)(nil) diff --git a/internal/store/postgres/star_gift_lifecycle_integration_test.go b/internal/store/postgres/star_gift_lifecycle_integration_test.go new file mode 100644 index 00000000..f3c82592 --- /dev/null +++ b/internal/store/postgres/star_gift_lifecycle_integration_test.go @@ -0,0 +1,827 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestStarGiftLifecycleAggregatePostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + now := int(time.Now().Unix()) + users := NewUserStore(pool) + buyer := createTestUser(t, ctx, users, "+1881"+suffix+"01", "GiftBuyer", "") + owner := createTestUser(t, ctx, users, "+1881"+suffix+"02", "GiftOwner", "") + offerBuyer := createTestUser(t, ctx, users, "+1881"+suffix+"03", "OfferBuyer", "") + resaleBuyer := createTestUser(t, ctx, users, "+1881"+suffix+"04", "ResaleBuyer", "") + loser := createTestUser(t, ctx, users, "+1881"+suffix+"05", "AuctionLoser", "") + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + + stars := NewStarsStore(pool) + for _, user := range []domain.User{buyer, owner, offerBuyer, resaleBuyer, loser} { + if _, _, err := stars.EnsureGrant(ctx, user.ID, 10000, now); err != nil { + t.Fatalf("grant stars to %d: %v", user.ID, err) + } + } + + gifts := NewStarGiftStore(pool) + baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000 + entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ + Title: "Lifecycle " + suffix, Stars: 50, ConvertStars: 20, Enabled: true, + Document: collectibleTestDocument(baseDocumentID, "lifecycle.tgs"), + Blob: collectibleTestBlob(baseDocumentID, "lifecycle"), Animation: collectibleTestAnimation("lifecycle.tgs"), + Actor: "integration", CommandID: "lifecycle-catalog-" + suffix, + }) + if err != nil { + t.Fatalf("create lifecycle catalog: %v", err) + } + if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ + GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 20, SlugPrefix: "life-" + suffix, + Models: []domain.StarGiftCollectibleAttribute{ + {Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs")}, + {Kind: domain.StarGiftCollectibleModel, Name: "Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true, + Document: collectibleTestDocumentPtr(baseDocumentID+2, "crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "crafted"), Animation: collectibleTestAnimationPtr("crafted.tgs")}, + }, + Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestDocumentPtr(baseDocumentID+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")}}, + Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Night", BackdropID: 77, + CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}}, + Actor: "integration", CommandID: "lifecycle-pool-" + suffix, + }); err != nil { + t.Fatalf("publish lifecycle pool: %v", err) + } + + messages := NewMessageStore(pool) + lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{ + StarsProceedsPermille: 900, TONProceedsPermille: 900, + })) + upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{ + TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500, + })) + + purchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer, + GiftID: entry.Gift.ID, CommandKey: "purchase-" + suffix, Date: now, Message: "hello"}) + purchased, err := lifecycle.PurchaseStarGift(ctx, purchaseReq) + if err != nil { + t.Fatalf("purchase gift: %v", err) + } + if purchased.Saved.ID <= 0 || purchased.Saved.MsgID <= 0 || purchased.Saved.PrepaidUpgradeHash == "" || purchased.Balance.Balance != 9950 { + t.Fatalf("purchase result = %+v", purchased) + } + ordinaryAction := purchased.Send.RecipientMessage.Media.ServiceAction.StarGift + if ordinaryAction == nil || !ordinaryAction.CanUpgrade || ordinaryAction.PrepaidUpgrade || + ordinaryAction.UpgradePriceStars != 100 || ordinaryAction.UpgradeStars != 0 { + t.Fatalf("ordinary purchase action mixed paid price with prepaid amount: %+v", ordinaryAction) + } + replayedPurchase, err := lifecycle.PurchaseStarGift(ctx, purchaseReq) + if err != nil || !replayedPurchase.Duplicate || replayedPurchase.Saved.ID != purchased.Saved.ID || replayedPurchase.Balance.Balance != 9950 || + replayedPurchase.Send.SenderMessage.ID != purchased.Send.SenderMessage.ID || + replayedPurchase.Send.RecipientMessage.ID != purchased.Send.RecipientMessage.ID { + t.Fatalf("purchase replay = %+v err %v", replayedPurchase, err) + } + + target, price, err := lifecycle.PrepaidUpgradeTarget(ctx, ownerPeer, purchased.Saved.PrepaidUpgradeHash) + if err != nil || target.ID != purchased.Saved.ID || price != 100 { + t.Fatalf("prepaid target = %+v price %d err %v", target, price, err) + } + prepaid, err := lifecycle.PrepayStarGiftUpgrade(ctx, domain.StarGiftPrepaidUpgradeRequest{ + PayerUserID: buyer.ID, Owner: ownerPeer, Hash: purchased.Saved.PrepaidUpgradeHash, + ChargeStars: 100, FormID: 11002, CommandKey: "prepay-" + suffix, Date: now + 1, + }) + if err != nil || prepaid.Saved.PrepaidUpgradeStars != 100 || prepaid.Saved.PrepaidUpgradeHash != "" || prepaid.Balance.Balance != 9850 { + t.Fatalf("prepay upgrade = %+v err %v", prepaid, err) + } + upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID}, + RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "upgrade-" + suffix, Date: now + 2, + }) + if err != nil { + t.Fatalf("upgrade prepaid gift: %v", err) + } + if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 || + upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails { + t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique) + } + upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique + ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID) + if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) || + ownerSourceEdit.Message.Media == nil || ownerSourceEdit.Message.Media.ServiceAction == nil || + ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil || + ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Saved.UpgradeMsgID || + ownerSourceEdit.Message.Media.ServiceAction.StarGift.CanUpgrade { + t.Fatalf("upgrade message linkage = action %+v source edit %+v", upgradeAction, ownerSourceEdit) + } + dropped, err := lifecycle.DropStarGiftOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{ + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID}, + ChargeStars: 25, FormID: 11003, CommandKey: "drop-" + suffix, Date: now + 3, + }) + if err != nil || dropped.Unique.KeepOriginalDetails || dropped.Saved.DropOriginalDetailsStars != 0 || dropped.Balance.Balance != 9975 { + t.Fatalf("drop original details = %+v err %v", dropped, err) + } + + // Expiry is driven by the background sweep, refunds exactly once and emits a + // durable declined/expired service message even when no user opens the offer. + expiring, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID, + Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 300}, + Duration: 120, RandomID: 22001, Date: now + 10, + }) + if err != nil || expiring.Balance.Balance != 9700 { + t.Fatalf("send expiring offer = %+v err %v", expiring, err) + } + if err := lifecycle.SweepStarGiftLifecycle(ctx, now+131, 1000); err != nil { + t.Fatalf("sweep expired offer: %v", err) + } + var expiredStatus string + var resolutionNotified bool + if err := pool.QueryRow(ctx, `SELECT status,resolution_notified FROM star_gift_offers WHERE id=$1`, expiring.Offer.ID). + Scan(&expiredStatus, &resolutionNotified); err != nil || expiredStatus != "expired" || !resolutionNotified { + t.Fatalf("expired offer state = %q notified %v err %v", expiredStatus, resolutionNotified, err) + } + if balance, err := stars.GetBalance(ctx, offerBuyer.ID); err != nil || balance.Balance != 10000 { + t.Fatalf("expired offer refund balance = %+v err %v", balance, err) + } + + // TON offers use the same durable offer state machine, but only mutate the + // internal telesrv TON ledger. Idempotent replay must report that ledger's + // balance instead of accidentally projecting the buyer's Stars balance. + tonOfferReq := domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID, + Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 300}, + Duration: 120, RandomID: 22003, Date: now + 132} + tonOffer, err := lifecycle.SendStarGiftOffer(ctx, tonOfferReq) + if err != nil || tonOffer.Balance.Balance != 999700 { + t.Fatalf("send TON offer = %+v err %v", tonOffer, err) + } + tonOfferReplay, err := lifecycle.SendStarGiftOffer(ctx, tonOfferReq) + if err != nil || !tonOfferReplay.Duplicate || tonOfferReplay.Balance.Balance != 999700 { + t.Fatalf("replay TON offer = %+v err %v", tonOfferReplay, err) + } + if _, err := lifecycle.ResolveStarGiftOffer(ctx, domain.StarGiftResolveOfferRequest{ + OwnerUserID: owner.ID, OfferMsgID: tonOffer.Offer.OfferMsgID, Decline: true, Date: now + 133, + }); err != nil { + t.Fatalf("decline TON offer: %v", err) + } + if balance, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || balance != 1_000_000 { + t.Fatalf("declined TON offer refund balance = %d err %v", balance, err) + } + + acceptedOffer, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID, + Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 300}, + Duration: 120, RandomID: 22002, Date: now + 140, + }) + if err != nil { + t.Fatalf("send accepted offer: %v", err) + } + accepted, err := lifecycle.ResolveStarGiftOffer(ctx, domain.StarGiftResolveOfferRequest{ + OwnerUserID: owner.ID, OfferMsgID: acceptedOffer.Offer.OfferMsgID, Date: now + 141, + }) + if err != nil || accepted.Offer.Status != "accepted" || accepted.Unique.Owner.ID != offerBuyer.ID || accepted.Saved.MsgID <= 0 { + t.Fatalf("accept offer = %+v err %v", accepted, err) + } + if balance, err := stars.GetBalance(ctx, owner.ID); err != nil || balance.Balance != 10245 { + t.Fatalf("offer seller balance = %+v err %v", balance, err) + } + var offerCommission int64 + if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, + fmt.Sprintf("offer:%d", acceptedOffer.Offer.ID)).Scan(&offerCommission); err != nil || offerCommission != 30 { + t.Fatalf("accepted Stars offer commission = %d err %v", offerCommission, err) + } + + listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: offerBuyer.ID, + Ref: domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: offerBuyer.ID}, MsgID: accepted.Saved.MsgID}, + Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, Date: now + 142, + }) + if err != nil || listed.ResellAmount == nil || listed.ResellAmount.Currency != domain.StarGiftCurrencyTON { + t.Fatalf("TON listing = %+v err %v", listed, err) + } + tonBefore, err := lifecycle.TonBalance(ctx, resaleBuyer.ID) + if err != nil || tonBefore != 1_000_000 { + t.Fatalf("resale buyer TON grant = %d err %v", tonBefore, err) + } + resold, err := lifecycle.PurchaseResaleStarGift(ctx, domain.StarGiftResalePurchaseRequest{ + BuyerUserID: resaleBuyer.ID, Slug: listed.Slug, To: domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}, + Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, FormID: 11004, + CommandKey: "resale-" + suffix, Date: now + 143, + }) + if err != nil || resold.Unique.Owner.ID != resaleBuyer.ID || resold.Balance.Balance != 999000 || resold.Saved.TransferStars != 25 { + t.Fatalf("TON resale = %+v err %v", resold, err) + } + if sellerTON, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || sellerTON != 1_000_900 { + t.Fatalf("TON seller local balance = %d err %v", sellerTON, err) + } + var resaleCommission int64 + if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, "resale-"+suffix). + Scan(&resaleCommission); err != nil || resaleCommission != 100 { + t.Fatalf("TON resale commission = %d err %v", resaleCommission, err) + } + tonPage, err := lifecycle.TonTransactions(ctx, resaleBuyer.ID, "", 20) + if err != nil || tonPage.Balance != 999000 || len(tonPage.Transactions) < 2 { + t.Fatalf("TON ledger page = %+v err %v", tonPage, err) + } + + transferred, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{ActorUserID: resaleBuyer.ID, + Ref: domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}, MsgID: resold.Saved.MsgID}, + To: ownerPeer, ChargeStars: 25, FormID: 11005, CommandKey: "transfer-back-" + suffix, Date: now + 144, + }) + if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 { + t.Fatalf("paid transfer = %+v err %v", transferred, err) + } + + // A second prepaid collectible makes craft chance exactly 1000‰. Success + // preserves the first aggregate as crafted and burns the other input. The + // fresh payment intent must create another gift even though buyer, owner and + // catalog gift are identical to the first purchase. + secondPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer, + GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "purchase-second-" + suffix, Date: now + 145}) + secondPurchase, err := lifecycle.PurchaseStarGift(ctx, secondPurchaseReq) + if err != nil { + t.Fatalf("purchase second prepaid gift: %v", err) + } + prepaidAction := secondPurchase.Send.RecipientMessage.Media.ServiceAction.StarGift + if prepaidAction == nil || !prepaidAction.PrepaidUpgrade || prepaidAction.UpgradePriceStars != 100 || prepaidAction.UpgradeStars != 100 { + t.Fatalf("prepaid purchase action lost price/entitlement split: %+v", prepaidAction) + } + secondUpgrade, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID, + Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: secondPurchase.Saved.MsgID}, RequirePrepaid: true, + CommandKey: "upgrade-second-" + suffix, Date: now + 146, + }) + if err != nil { + t.Fatalf("upgrade second prepaid gift: %v", err) + } + listedForCraft, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID, + Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, + Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, Date: now + 146, + }) + if err != nil || listedForCraft.ResellAmount == nil || listedForCraft.ResellAmount.Amount != 125 { + t.Fatalf("list craft input = %+v err %v", listedForCraft, err) + } + loserBalanceBeforeOffer, err := stars.GetBalance(ctx, loser.ID) + if err != nil { + t.Fatalf("craft offer buyer balance: %v", err) + } + pendingCraftOffer, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: loser.ID, + Owner: ownerPeer, Slug: transferred.Unique.Slug, + Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, + Duration: 120, RandomID: 22003, Date: now + 146, + }) + if err != nil || pendingCraftOffer.Offer.Status != "pending" { + t.Fatalf("pending craft offer = %+v err %v", pendingCraftOffer, err) + } + resolvedCraftIDs, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{ + {Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, + {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, + }) + if err != nil || len(resolvedCraftIDs) != 2 || resolvedCraftIDs[0] != transferred.Saved.ID || resolvedCraftIDs[1] != secondUpgrade.Saved.ID { + t.Fatalf("resolve mixed craft refs = %v err %v", resolvedCraftIDs, err) + } + if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{ + {Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID}, + {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, + }); !errors.Is(err, domain.ErrStarGiftNotFound) { + t.Fatalf("upgrade message id lookup err = %v, want ErrStarGiftNotFound", err) + } + if saved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{ + Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID, + }); err != nil || found { + t.Fatalf("upgrade message id resolved a gift: saved=%+v found=%v err=%v", saved, found, err) + } + if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{ + {Owner: ownerPeer, MsgID: secondUpgrade.Saved.MsgID}, + {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, + }); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + t.Fatalf("duplicate official identities err = %v", err) + } + crafted, err := lifecycle.CraftStarGift(ctx, domain.StarGiftCraftRequest{UserID: owner.ID, + Refs: []domain.SavedStarGiftRef{ + {Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, + // TDesktop sends collectibles without a manage id as the official + // inputSavedStarGiftSlug alias. + {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, + }, CommandKey: "craft-" + suffix, Date: now + 147, + }) + if err != nil || !crafted.Success || crafted.Chance != 1000 || crafted.Gift == nil || !crafted.Gift.Crafted || crafted.Send.RecipientMessage.ID <= 0 { + t.Fatalf("craft result = %+v err %v", crafted, err) + } + craftedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, transferred.Unique.ID) + craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit) + burnedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, secondUpgrade.Unique.ID) + burnedInputAction := starGiftUniqueActionFromEdit(burnedInputEdit) + if craftedInputAction == nil || !craftedInputAction.Gift.Crafted || craftedInputAction.Gift.Burned || + craftedInputAction.Gift.CraftChancePermille != 0 || !craftedInputAction.Saved || craftedInputAction.CanCraftAt != 0 { + t.Fatalf("crafted input message projection = %+v", craftedInputAction) + } + if burnedInputAction == nil || !burnedInputAction.Gift.Burned || burnedInputAction.Gift.CraftChancePermille != 0 || + burnedInputAction.Saved || burnedInputAction.CanCraftAt != 0 { + t.Fatalf("burned input message projection = %+v", burnedInputAction) + } + craftReq := domain.StarGiftCraftRequest{UserID: owner.ID, + Refs: []domain.SavedStarGiftRef{ + {Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, + {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, + }, CommandKey: "craft-" + suffix, Date: now + 147, + } + craftedReplay, err := lifecycle.CraftStarGift(ctx, craftReq) + if err != nil || !craftedReplay.Duplicate || !craftedReplay.Success || craftedReplay.Gift == nil || + craftedReplay.Send.RecipientMessage.ID != crafted.Send.RecipientMessage.ID || + craftedSourceEditForUserAndGift(craftedReplay, owner.ID, transferred.Unique.ID).Event.Pts != craftedInputEdit.Event.Pts || + craftedSourceEditForUserAndGift(craftedReplay, owner.ID, secondUpgrade.Unique.ID).Event.Pts != burnedInputEdit.Event.Pts { + t.Fatalf("craft success replay = %+v err %v", craftedReplay, err) + } + var craftListings, resaleAvailability int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings WHERE unique_gift_id=ANY($1::bigint[])`, + []int64{transferred.Unique.ID, secondUpgrade.Unique.ID}).Scan(&craftListings); err != nil || craftListings != 0 { + t.Fatalf("craft input listings = %d err %v", craftListings, err) + } + if err := pool.QueryRow(ctx, `SELECT availability_resale FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&resaleAvailability); err != nil || resaleAvailability != 0 { + t.Fatalf("craft resale projection = %d err %v", resaleAvailability, err) + } + var craftOfferStatus string + if err := pool.QueryRow(ctx, `SELECT status FROM star_gift_offers WHERE id=$1`, pendingCraftOffer.Offer.ID).Scan(&craftOfferStatus); err != nil || craftOfferStatus != "cancelled" { + t.Fatalf("craft offer status = %q err %v", craftOfferStatus, err) + } + loserBalanceAfterCraft, err := stars.GetBalance(ctx, loser.ID) + if err != nil || loserBalanceAfterCraft.Balance != loserBalanceBeforeOffer.Balance { + t.Fatalf("craft offer refund balance = %+v err %v, want %d", loserBalanceAfterCraft, err, loserBalanceBeforeOffer.Balance) + } + var secondStatus string + if err := pool.QueryRow(ctx, `SELECT lifecycle_status FROM peer_star_gifts WHERE id=$1`, secondUpgrade.Saved.ID).Scan(&secondStatus); err != nil || secondStatus != "burned" { + t.Fatalf("second craft input status = %q err %v", secondStatus, err) + } + + // A failed draw is just as terminal as success: the input aggregate and both + // users' message snapshots are burned in the outcome transaction. An exact + // retry replays the receipt, while a fresh command cannot consume it again. + thirdPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer, + GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "purchase-third-" + suffix, Date: now + 148}) + thirdPurchase, err := lifecycle.PurchaseStarGift(ctx, thirdPurchaseReq) + if err != nil { + t.Fatalf("purchase third prepaid gift: %v", err) + } + thirdUpgrade, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID, + Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: thirdPurchase.Saved.MsgID}, RequirePrepaid: true, + CommandKey: "upgrade-third-" + suffix, Date: now + 149, + }) + if err != nil { + t.Fatalf("upgrade third prepaid gift: %v", err) + } + failingLifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, + WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{StarsProceedsPermille: 900, TONProceedsPermille: 900}), + WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil })) + failureReq := domain.StarGiftCraftRequest{UserID: owner.ID, + Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.MsgID}}, + CommandKey: "craft-fail-" + suffix, Date: now + 150, + } + failedCraft, err := failingLifecycle.CraftStarGift(ctx, failureReq) + if err != nil || failedCraft.Success || failedCraft.Chance != 500 || failedCraft.Gift != nil { + t.Fatalf("craft failure result = %+v err %v", failedCraft, err) + } + failedInputEdit := craftedSourceEditForUserAndGift(failedCraft, owner.ID, thirdUpgrade.Unique.ID) + failedInputAction := starGiftUniqueActionFromEdit(failedInputEdit) + if failedInputAction == nil || !failedInputAction.Gift.Burned || failedInputAction.Gift.CraftChancePermille != 0 || + failedInputAction.Gift.OfferMinStars != 0 || failedInputAction.Saved || failedInputAction.CanCraftAt != 0 { + t.Fatalf("failed craft message projection = %+v", failedInputAction) + } + var failedLifecycle string + var failedUnsaved bool + var failedTransferStars int64 + var failedCanExportAt, failedCanTransferAt, failedCanResellAt, failedCanCraftAt int + var failedDropStars int64 + if err := pool.QueryRow(ctx, `SELECT lifecycle_status,unsaved,transfer_stars,can_export_at,can_transfer_at, +can_resell_at,drop_original_details_stars,can_craft_at FROM peer_star_gifts WHERE id=$1`, thirdUpgrade.Saved.ID). + Scan(&failedLifecycle, &failedUnsaved, &failedTransferStars, &failedCanExportAt, &failedCanTransferAt, + &failedCanResellAt, &failedDropStars, &failedCanCraftAt); err != nil || failedLifecycle != "burned" || !failedUnsaved || + failedTransferStars != 0 || failedCanExportAt != 0 || failedCanTransferAt != 0 || failedCanResellAt != 0 || + failedDropStars != 0 || failedCanCraftAt != 0 { + t.Fatalf("failed craft saved aggregate = status %q unsaved %v transfer %d export %d transfer_at %d resale %d drop %d craft %d err %v", + failedLifecycle, failedUnsaved, failedTransferStars, failedCanExportAt, failedCanTransferAt, + failedCanResellAt, failedDropStars, failedCanCraftAt, err) + } + var failedBurned bool + var failedChance, failedOfferMin int + if err := pool.QueryRow(ctx, `SELECT burned,craft_chance_permille,offer_min_stars FROM unique_star_gifts WHERE id=$1`, thirdUpgrade.Unique.ID). + Scan(&failedBurned, &failedChance, &failedOfferMin); err != nil || !failedBurned || failedChance != 0 || failedOfferMin != 0 { + t.Fatalf("failed craft unique aggregate = burned %v chance %d offer %d err %v", failedBurned, failedChance, failedOfferMin, err) + } + failedReplay, err := failingLifecycle.CraftStarGift(ctx, failureReq) + if err != nil || !failedReplay.Duplicate || failedReplay.Success || failedReplay.Chance != failedCraft.Chance || + craftedSourceEditForUserAndGift(failedReplay, owner.ID, thirdUpgrade.Unique.ID).Event.Pts != failedInputEdit.Event.Pts { + t.Fatalf("craft failure replay = %+v err %v", failedReplay, err) + } + invalidRetry := failureReq + invalidRetry.CommandKey = "craft-fail-new-command-" + suffix + if _, err := failingLifecycle.CraftStarGift(ctx, invalidRetry); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) { + t.Fatalf("fresh command reused burned craft input: %v", err) + } + craftCandidates, err := lifecycle.ListCraftStarGifts(ctx, owner.ID, entry.Gift.ID, "", 20) + if err != nil || craftCandidates.Count != 0 || len(craftCandidates.Gifts) != 0 { + t.Fatalf("terminal craft inputs remained candidates: %+v err %v", craftCandidates, err) + } + + withdrawalReq := domain.StarGiftWithdrawalRequest{UserID: owner.ID, + Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, Date: now + 151} + recorded, err := lifecycle.RecordStarGiftWithdrawal(ctx, withdrawalReq, "local", "withdraw-"+suffix, + "https://telesrv.invalid/gift-withdrawal/"+suffix, now+748) + if err != nil || recorded.Status != "pending" { + t.Fatalf("record local withdrawal = %+v err %v", recorded, err) + } + completed, err := lifecycle.CompleteStarGiftWithdrawal(ctx, recorded.ProviderRequestID, now+152) + if err != nil || completed.Status != "completed" || completed.Gift.OwnerAddress == "" || completed.Gift.GiftAddress == "" { + t.Fatalf("complete local withdrawal = %+v err %v", completed, err) + } + + // Auction winner reservation is consumed; the unreachable lower bid is + // refunded atomically. Award delivery is durable and includes gift_num. + auctionEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ + Title: "Auction " + suffix, Stars: 100, Enabled: true, Limited: true, Auction: true, + AvailabilityTotal: 1, AvailabilityRemains: 1, GiftsPerRound: 1, AuctionStartDate: now - 10, + AuctionSlug: "auction-" + suffix, + Document: collectibleTestDocument(baseDocumentID+100, "auction.tgs"), + Blob: collectibleTestBlob(baseDocumentID+100, "auction"), Animation: collectibleTestAnimation("auction.tgs"), + Actor: "integration", CommandID: "auction-catalog-" + suffix, + }) + if err != nil { + t.Fatalf("create auction catalog: %v", err) + } + winnerState, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: resaleBuyer.ID, + GiftID: auctionEntry.Gift.ID, Peer: ownerPeer, BidAmount: 200, FormID: 12001, Date: now, Message: "winner"}) + if err != nil || winnerState.UserState.BidAmount != 200 { + t.Fatalf("winner bid state = %+v err %v", winnerState, err) + } + if _, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: loser.ID, + GiftID: auctionEntry.Gift.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: loser.ID}, + BidAmount: 150, FormID: 12002, Date: now + 1}); err != nil { + t.Fatalf("loser bid: %v", err) + } + if _, err := pool.Exec(ctx, `UPDATE star_gift_auctions SET next_round_at=$2 WHERE gift_id=$1`, auctionEntry.Gift.ID, now+2); err != nil { + t.Fatalf("make auction round due: %v", err) + } + if err := lifecycle.SweepStarGiftLifecycle(ctx, now+2, 1000); err != nil { + t.Fatalf("settle auction sweep: %v", err) + } + acquired, err := lifecycle.StarGiftAuctionAcquired(ctx, resaleBuyer.ID, auctionEntry.Gift.ID) + if err != nil || len(acquired) != 1 || acquired[0].GiftNum != 1 || acquired[0].BidAmount != 200 { + t.Fatalf("auction acquired = %+v err %v", acquired, err) + } + if loserBalance, err := stars.GetBalance(ctx, loser.ID); err != nil || loserBalance.Balance != 10000 { + t.Fatalf("auction loser refund = %+v err %v", loserBalance, err) + } + var auctionSavedCount int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE gift_id=$1 AND gift_num=1 AND convert_stars=0`, auctionEntry.Gift.ID). + Scan(&auctionSavedCount); err != nil || auctionSavedCount != 1 { + t.Fatalf("auction saved award count = %d err %v", auctionSavedCount, err) + } + +} + +func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + now := int(time.Now().Unix()) + users := NewUserStore(pool) + actor := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ChannelGiftActor", "") + if _, _, err := NewStarsStore(pool).EnsureGrant(ctx, actor.ID, 10000, now); err != nil { + t.Fatalf("grant actor stars: %v", err) + } + created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: actor.ID, Title: "Gift Channel " + suffix, Megagroup: true, Date: now, + }) + if err != nil { + t.Fatalf("create gift channel: %v", err) + } + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + createdTarget, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: actor.ID, Title: "Gift Target Channel " + suffix, Megagroup: true, Date: now, + }) + if err != nil { + t.Fatalf("create target gift channel: %v", err) + } + targetChannelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: createdTarget.Channel.ID} + gifts := NewStarGiftStore(pool) + baseDocumentID := (time.Now().UnixNano() & 0x7ffffffffffff000) + 500 + entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ + Title: "Channel Gift " + suffix, Stars: 50, ConvertStars: 20, Enabled: true, Limited: true, + AvailabilityTotal: 5, AvailabilityRemains: 5, + Document: collectibleTestDocument(baseDocumentID, "channel-gift.tgs"), Blob: collectibleTestBlob(baseDocumentID, "channel-gift"), + Animation: collectibleTestAnimation("channel-gift.tgs"), Actor: "integration", CommandID: "channel-gift-" + suffix, + }) + if err != nil { + t.Fatalf("create channel gift catalog: %v", err) + } + if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ + GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 5, SlugPrefix: "channel-life-" + suffix, + Models: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")}}, + Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestDocumentPtr(baseDocumentID+2, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")}}, + Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Channel Backdrop", BackdropID: 88, + CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}}, + Actor: "integration", CommandID: "channel-gift-pool-" + suffix, + }); err != nil { + t.Fatalf("publish channel gift pool: %v", err) + } + messages := NewMessageStore(pool) + lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{ + StarsProceedsPermille: 900, TONProceedsPermille: 900, + })) + upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{ + TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500, + })) + channelPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer, + GiftID: entry.Gift.ID, CommandKey: "channel-purchase-" + suffix, Date: now + 1}) + purchased, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq) + if err != nil || purchased.Saved.SavedID <= 0 || purchased.Balance.Balance != 9950 { + t.Fatalf("atomic channel purchase = %+v err %v", purchased, err) + } + var regularLogs int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(®ularLogs); err != nil || regularLogs != 1 { + t.Fatalf("channel purchase admin logs = %d err %v", regularLogs, err) + } + var channelPrice string + var channelPrepaidAmount any + if err := pool.QueryRow(ctx, `SELECT message #>> '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}' +FROM channel_admin_log_events WHERE channel_id=$1 AND event_type='send_message' ORDER BY id DESC LIMIT 1`, created.Channel.ID). + Scan(&channelPrice, &channelPrepaidAmount); err != nil || channelPrice != "100" || channelPrepaidAmount != nil { + t.Fatalf("channel ordinary action price=%q prepaid=%v err=%v", channelPrice, channelPrepaidAmount, err) + } + if replay, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq); err != nil || !replay.Duplicate { + t.Fatalf("channel purchase replay = %+v err %v", replay, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(®ularLogs); err != nil || regularLogs != 1 { + t.Fatalf("channel replay duplicated admin log count=%d err %v", regularLogs, err) + } + + converted, err := lifecycle.ConvertStarGift(ctx, domain.StarGiftConvertRequest{ActorUserID: actor.ID, + Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: purchased.Saved.SavedID}, Date: now + 2}) + if err != nil || !converted.Saved.Converted || converted.OwnerBalance != 20 { + t.Fatalf("atomic channel conversion = %+v err %v", converted, err) + } + var channelBalance, conversionRows, conversionTxns int64 + if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 20 { + t.Fatalf("channel conversion balance = %d err %v", channelBalance, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_conversions WHERE saved_gift_id=$1`, purchased.Saved.ID).Scan(&conversionRows); err != nil || conversionRows != 1 { + t.Fatalf("channel conversion command rows = %d err %v", conversionRows, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_stars_transactions WHERE channel_id=$1 AND gift_id=$2`, created.Channel.ID, entry.Gift.ID).Scan(&conversionTxns); err != nil || conversionTxns != 1 { + t.Fatalf("channel conversion transactions = %d err %v", conversionTxns, err) + } + if balance, err := lifecycle.ChannelStarsBalance(ctx, created.Channel.ID); err != nil || balance != 20 { + t.Fatalf("channel stars balance projection = %d err %v", balance, err) + } + starsPage, err := lifecycle.ChannelStarsTransactions(ctx, created.Channel.ID, "", 20) + if err != nil || starsPage.Balance != 20 || len(starsPage.Transactions) != 1 || + starsPage.Transactions[0].Amount != 20 || starsPage.Transactions[0].Reason != domain.StarsReasonGift { + t.Fatalf("channel stars transaction projection = %+v err %v", starsPage, err) + } + if _, err := lifecycle.ConvertStarGift(ctx, domain.StarGiftConvertRequest{ActorUserID: actor.ID, + Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: purchased.Saved.SavedID}, Date: now + 3}); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) { + t.Fatalf("repeated channel conversion err = %v, want already converted", err) + } + if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 20 { + t.Fatalf("channel balance after replay = %d err %v", channelBalance, err) + } + + // A third party may prepay the upgrade entitlement of a channel-owned gift. + // The payer's personal Stars and the channel saved-gift entitlement commit + // together; the payment is also visible in channel Recent Actions. + channelPrepayTargetReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer, + GiftID: entry.Gift.ID, CommandKey: "channel-prepay-target-" + suffix, Date: now + 4}) + channelPrepayTarget, err := lifecycle.PurchaseStarGift(ctx, channelPrepayTargetReq) + if err != nil || channelPrepayTarget.Saved.PrepaidUpgradeHash == "" { + t.Fatalf("channel prepay target purchase = %+v err %v", channelPrepayTarget, err) + } + prepayTarget, prepayPrice, err := lifecycle.PrepaidUpgradeTarget(ctx, channelPeer, channelPrepayTarget.Saved.PrepaidUpgradeHash) + if err != nil || prepayTarget.ID != channelPrepayTarget.Saved.ID || prepayPrice != 100 { + t.Fatalf("channel prepay target = %+v price=%d err=%v", prepayTarget, prepayPrice, err) + } + channelPrepayReq := domain.StarGiftPrepaidUpgradeRequest{ + PayerUserID: actor.ID, Owner: channelPeer, Hash: channelPrepayTarget.Saved.PrepaidUpgradeHash, + ChargeStars: 100, FormID: 21006, CommandKey: "channel-prepay-" + suffix, Date: now + 4, + } + channelPrepay, err := lifecycle.PrepayStarGiftUpgrade(ctx, channelPrepayReq) + if err != nil || channelPrepay.Saved.PrepaidUpgradeStars != 100 || channelPrepay.Saved.PrepaidUpgradeHash != "" || + channelPrepay.Send.RecipientMessage.OwnerUserID != actor.ID { + t.Fatalf("channel prepaid entitlement = %+v err %v", channelPrepay, err) + } + var prepayLogs int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 { + t.Fatalf("channel prepaid upgrade admin logs = %d err %v", prepayLogs, err) + } + channelPrepayReplay, err := lifecycle.PrepayStarGiftUpgrade(ctx, channelPrepayReq) + if err != nil || !channelPrepayReplay.Duplicate || channelPrepayReplay.Saved.ID != channelPrepay.Saved.ID { + t.Fatalf("channel prepaid entitlement replay = %+v err %v", channelPrepayReplay, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 { + t.Fatalf("channel prepaid upgrade replay logs = %d err %v", prepayLogs, err) + } + + var ptsBeforeUpgrade int + if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsBeforeUpgrade); err != nil { + t.Fatal(err) + } + prepaidPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer, + GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "channel-prepaid-purchase-" + suffix, Date: now + 4}) + prepaidPurchase, err := lifecycle.PurchaseStarGift(ctx, prepaidPurchaseReq) + if err != nil || prepaidPurchase.Saved.PrepaidUpgradeStars != 100 || prepaidPurchase.Saved.SavedID <= 0 { + t.Fatalf("channel prepaid gift purchase = %+v err %v", prepaidPurchase, err) + } + upgradeReq := domain.StarGiftUpgradeRequest{UserID: actor.ID, + Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: prepaidPurchase.Saved.SavedID}, RequirePrepaid: true, + KeepOriginalDetails: true, CommandKey: "channel-upgrade-" + suffix, Date: now + 5, + } + upgraded, err := upgrades.UpgradeStarGift(ctx, upgradeReq) + if err != nil || upgraded.Saved.Owner != channelPeer || upgraded.Unique.Owner != channelPeer || + upgraded.Saved.SavedID != prepaidPurchase.Saved.SavedID || upgraded.Send.RecipientMessage.OwnerUserID != actor.ID { + t.Fatalf("channel prepaid upgrade = %+v err %v", upgraded, err) + } + action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique + if action == nil || action.FromUserID != domain.OfficialSystemUserID || action.Peer != channelPeer || + action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 { + t.Fatalf("channel upgrade service action = %+v", action) + } + var ptsAfterUpgrade int + if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsAfterUpgrade); err != nil || ptsAfterUpgrade != ptsBeforeUpgrade { + t.Fatalf("channel pts after profile gift upgrade = %d want %d err %v", ptsAfterUpgrade, ptsBeforeUpgrade, err) + } + var upgradeLogs int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channel.ID).Scan(&upgradeLogs); err != nil || upgradeLogs != 1 { + t.Fatalf("channel upgrade admin logs = %d err %v", upgradeLogs, err) + } + replayedUpgrade, err := upgrades.UpgradeStarGift(ctx, upgradeReq) + if err != nil || !replayedUpgrade.Duplicate || replayedUpgrade.Unique.ID != upgraded.Unique.ID { + t.Fatalf("channel upgrade replay = %+v err %v", replayedUpgrade, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channel.ID).Scan(&upgradeLogs); err != nil || upgradeLogs != 1 { + t.Fatalf("channel upgrade replay admin logs = %d err %v", upgradeLogs, err) + } + dropped, err := lifecycle.DropStarGiftOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{ + UserID: actor.ID, Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: upgraded.Saved.SavedID}, + ChargeStars: 25, FormID: 21007, CommandKey: "channel-drop-details-" + suffix, Date: now + 6, + }) + if err != nil || dropped.Saved.Owner != channelPeer || dropped.Unique.KeepOriginalDetails || dropped.Saved.DropOriginalDetailsStars != 0 { + t.Fatalf("channel drop original details = %+v err %v", dropped, err) + } + + listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: actor.ID, + Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: upgraded.Saved.SavedID}, + Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, Date: now + 6, + }) + if err != nil || listed.ResellAmount == nil || listed.Owner != channelPeer { + t.Fatalf("list channel collectible = %+v err %v", listed, err) + } + if balance, err := lifecycle.TonBalance(ctx, actor.ID); err != nil || balance != 1_000_000 { + t.Fatalf("channel resale buyer local TON grant = %d err %v", balance, err) + } + resaleReq := domain.StarGiftResalePurchaseRequest{BuyerUserID: actor.ID, Slug: listed.Slug, To: targetChannelPeer, + Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, FormID: 21004, + CommandKey: "channel-to-channel-resale-" + suffix, Date: now + 7, + } + resold, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq) + if err != nil || resold.Unique.Owner != targetChannelPeer || resold.Saved.Owner != targetChannelPeer || + resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 { + t.Fatalf("channel-to-channel local TON resale = %+v err %v", resold, err) + } + var channelTON, channelTONTxns, targetResaleLogs, commission int64 + if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 { + t.Fatalf("channel local TON proceeds = %d err %v", channelTON, err) + } + if balance, err := lifecycle.ChannelTonBalance(ctx, created.Channel.ID); err != nil || balance != 900 { + t.Fatalf("channel ton balance projection = %d err %v", balance, err) + } + tonPage, err := lifecycle.ChannelTonTransactions(ctx, created.Channel.ID, "", 20) + if err != nil || tonPage.Balance != 900 || len(tonPage.Transactions) != 1 || + tonPage.Transactions[0].Amount != 900 || tonPage.Transactions[0].Reason != domain.StarsReasonGiftResale { + t.Fatalf("channel ton transaction projection = %+v err %v", tonPage, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_ton_transactions WHERE channel_id=$1 AND gift_id=$2`, created.Channel.ID, listed.ID).Scan(&channelTONTxns); err != nil || channelTONTxns != 1 { + t.Fatalf("channel local TON transactions = %d err %v", channelTONTxns, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, + createdTarget.Channel.ID).Scan(&targetResaleLogs); err != nil || targetResaleLogs != 1 { + t.Fatalf("target channel resale admin logs = %d err %v", targetResaleLogs, err) + } + if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, resaleReq.CommandKey).Scan(&commission); err != nil || commission != 100 { + t.Fatalf("channel TON resale commission = %d err %v", commission, err) + } + resaleReplay, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq) + if err != nil || !resaleReplay.Duplicate || resaleReplay.Unique.ID != resold.Unique.ID { + t.Fatalf("channel resale replay = %+v err %v", resaleReplay, err) + } + if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 { + t.Fatalf("channel TON proceeds after replay = %d err %v", channelTON, err) + } + + var remainsBefore int + if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsBefore); err != nil { + t.Fatal(err) + } + balanceBefore, _ := NewStarsStore(pool).GetBalance(ctx, actor.ID) + invalidChannelReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, + To: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID + 999999}, GiftID: entry.Gift.ID, + CommandKey: "invalid-channel-purchase-" + suffix, Date: now + 2}) + _, err = lifecycle.PurchaseStarGift(ctx, invalidChannelReq) + if err == nil { + t.Fatal("purchase to missing channel unexpectedly succeeded") + } + var remainsAfter int + if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsAfter); err != nil || remainsAfter != remainsBefore { + t.Fatalf("inventory after rolled-back channel purchase = %d want %d err %v", remainsAfter, remainsBefore, err) + } + if balanceAfter, err := NewStarsStore(pool).GetBalance(ctx, actor.ID); err != nil || balanceAfter.Balance != balanceBefore.Balance { + t.Fatalf("balance after rolled-back channel purchase = %+v want %+v err %v", balanceAfter, balanceBefore, err) + } + + auctionEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ + Title: "Channel Auction " + suffix, Stars: 100, Enabled: true, Limited: true, Auction: true, + AvailabilityTotal: 1, AvailabilityRemains: 1, GiftsPerRound: 1, AuctionStartDate: now - 10, + AuctionSlug: "channel-auction-" + suffix, + Document: collectibleTestDocument(baseDocumentID+100, "channel-auction.tgs"), Blob: collectibleTestBlob(baseDocumentID+100, "channel-auction"), + Animation: collectibleTestAnimation("channel-auction.tgs"), Actor: "integration", CommandID: "channel-auction-" + suffix, + }) + if err != nil { + t.Fatalf("create channel auction: %v", err) + } + if _, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: actor.ID, + GiftID: auctionEntry.Gift.ID, Peer: channelPeer, BidAmount: 100, FormID: 22001, Date: now + 3, + }); err != nil { + t.Fatalf("bid channel auction: %v", err) + } + if _, err := pool.Exec(ctx, `UPDATE star_gift_auctions SET next_round_at=$2 WHERE gift_id=$1`, auctionEntry.Gift.ID, now+4); err != nil { + t.Fatal(err) + } + if err := lifecycle.SweepStarGiftLifecycle(ctx, now+4, 1000); err != nil { + t.Fatalf("settle channel auction: %v", err) + } + var awardSavedID int64 + if err := pool.QueryRow(ctx, `SELECT saved_gift_id FROM star_gift_auction_acquired WHERE gift_id=$1`, auctionEntry.Gift.ID).Scan(&awardSavedID); err != nil || awardSavedID <= 0 { + t.Fatalf("channel auction saved id = %d err %v", awardSavedID, err) + } + var awardLogs int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND message::text LIKE '%auction_acquired%'`, created.Channel.ID).Scan(&awardLogs); err != nil || awardLogs != 1 { + t.Fatalf("channel auction admin logs = %d err %v", awardLogs, err) + } +} + +func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *StarGiftLifecycleStore, + req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest { + t.Helper() + var revisionID int64 + if err := lifecycle.db.QueryRow(ctx, `SELECT active_revision_id FROM star_gift_catalog WHERE gift_id=$1`, req.GiftID).Scan(&revisionID); err != nil { + t.Fatalf("load active gift revision: %v", err) + } + gift, found, err := NewStarGiftStore(lifecycle.db).CatalogRevision(ctx, revisionID) + if err != nil || !found { + t.Fatalf("load gift revision %d: found=%v err=%v", revisionID, found, err) + } + req.RevisionID = gift.RevisionID + req.ChargeStars = gift.Stars + if req.IncludeUpgrade { + req.ChargeStars += gift.UpgradeStars + } + issued, err := lifecycle.IssueStarGiftPurchaseForm(ctx, domain.StarGiftPurchaseForm{ + BuyerUserID: req.BuyerUserID, To: req.To, GiftID: req.GiftID, RevisionID: req.RevisionID, + IncludeUpgrade: req.IncludeUpgrade, HideName: req.HideName, Message: req.Message, ChargeStars: req.ChargeStars, + IssuedAt: req.Date, ExpiresAt: req.Date + 600, + }) + if err != nil { + t.Fatalf("issue purchase form: %v", err) + } + req.FormID = issued.FormID + return req +} + +func craftedSourceEditForUserAndGift(result domain.StarGiftCraftResult, userID, uniqueGiftID int64) domain.EditedMessageForUser { + for _, edit := range result.SourceEdits { + if edit.UserID != userID { + continue + } + action := starGiftUniqueActionFromEdit(edit) + if action != nil && action.Gift.ID == uniqueGiftID { + return edit + } + } + return domain.EditedMessageForUser{UserID: userID} +} + +func starGiftUniqueActionFromEdit(edit domain.EditedMessageForUser) *domain.MessageStarGiftUniqueAction { + if edit.Message.Media == nil || edit.Message.Media.ServiceAction == nil { + return nil + } + return edit.Message.Media.ServiceAction.StarGiftUnique +} diff --git a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go new file mode 100644 index 00000000..ddff2764 --- /dev/null +++ b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go @@ -0,0 +1,20 @@ +package postgres + +import ( + "os" + "testing" +) + +func TestStarGiftLifecycleMigrationsApply(t *testing.T) { + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test") + } + status, err := MigrateAndStatus(dsn) + if err != nil { + t.Fatalf("migrate star gift lifecycle schema: %v", err) + } + if status.Dirty || status.Empty || status.Version != 105 { + t.Fatalf("migration status = %+v, want clean version 105", status) + } +} diff --git a/internal/store/postgres/star_gift_official_import_integration_test.go b/internal/store/postgres/star_gift_official_import_integration_test.go new file mode 100644 index 00000000..d6463580 --- /dev/null +++ b/internal/store/postgres/star_gift_official_import_integration_test.go @@ -0,0 +1,94 @@ +package postgres + +import ( + "context" + "errors" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestOfficialStarGiftBundleIsAtomicPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + store := NewStarGiftStore(pool) + baseID := time.Now().UnixNano() & 0x7ffffffffffff000 + manifestSHA := make([]byte, 32) + for i := range manifestSHA { + manifestSHA[i] = 0x5a + } + attribute := func(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute { + value := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 918} + if kind == domain.StarGiftCollectibleBackdrop { + value.BackdropID = 0 + value.CenterColor, value.EdgeColor, value.PatternColor, value.TextColor = 1, 2, 3, 4 + return value + } + value.Document = collectibleTestDocumentPtr(id, name+".tgs") + value.Blob = collectibleTestBlobPtr(id, name) + value.Animation = collectibleTestAnimationPtr(name + ".tgs") + value.OfficialDocumentID = 5200000000000000000 + id%1000 + return value + } + bundle := domain.StarGiftCatalogBundleWrite{ + Catalog: domain.StarGiftCatalogWrite{ + Title: "Official", Stars: 50, ConvertStars: 25, Enabled: true, + Document: collectibleTestDocument(baseID, "official.tgs"), Blob: collectibleTestBlob(baseID, "official"), + Animation: collectibleTestAnimation("official.tgs"), Actor: "integration", CommandID: "official-catalog-" + suffix, + OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA, + OfficialSourceJSON: []byte(`{"id":5170145012310081615,"sold_out":true,"birthday":false}`), + }, + Collectible: &domain.StarGiftCollectibleWrite{ + UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "official-" + suffix, + Models: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleModel, baseID+1, "model")}, + Patterns: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectiblePattern, baseID+2, "pattern")}, + Backdrops: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop")}, + Actor: "integration", CommandID: "official-pool-" + suffix, + OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA, + }, + } + result, err := store.CreateCatalogBundle(ctx, bundle) + if err != nil { + t.Fatalf("create official bundle: %v", err) + } + if result.Catalog.Gift.ID == 0 || result.Collectible == nil || result.Catalog.Gift.UpgradeStars != 100 { + t.Fatalf("bundle result = %+v", result) + } + var sourceID int64 + var soldOut bool + if err := pool.QueryRow(ctx, ` +SELECT official_gift_id, (official_source->>'sold_out')::boolean +FROM star_gift_catalog_revisions WHERE id=$1`, result.Catalog.Gift.RevisionID).Scan(&sourceID, &soldOut); err != nil { + t.Fatal(err) + } + if sourceID != 5170145012310081615 || !soldOut { + t.Fatalf("source id=%d sold_out=%v", sourceID, soldOut) + } + + failing := bundle + failing.Catalog.CommandID = "official-rollback-" + suffix + failing.Catalog.Document = collectibleTestDocument(baseID+100, "rollback.tgs") + failing.Catalog.Blob = collectibleTestBlob(baseID+100, "rollback") + failing.Collectible = &domain.StarGiftCollectibleWrite{ + UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "rollback-" + suffix, + Models: []domain.StarGiftCollectibleAttribute{ + attribute(domain.StarGiftCollectibleModel, baseID+101, "duplicate"), + attribute(domain.StarGiftCollectibleModel, baseID+102, "duplicate"), + }, + Patterns: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectiblePattern, baseID+103, "pattern")}, + Backdrops: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop")}, + Actor: "integration", CommandID: "rollback-pool-" + suffix, + } + if _, err := store.CreateCatalogBundle(ctx, failing); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + t.Fatalf("failing bundle err=%v", err) + } + var rows int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM star_gift_catalog_revisions WHERE command_id=$1`, failing.Catalog.CommandID).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 0 { + t.Fatalf("failed bundle left %d catalog revisions", rows) + } +} diff --git a/internal/store/postgres/star_gift_purchase.go b/internal/store/postgres/star_gift_purchase.go new file mode 100644 index 00000000..7e2efca4 --- /dev/null +++ b/internal/store/postgres/star_gift_purchase.go @@ -0,0 +1,344 @@ +package postgres + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *StarGiftLifecycleStore) IssueStarGiftPurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) { + if s == nil || s.db == nil || form.FormID != 0 || form.BuyerUserID <= 0 || !validLifecyclePeer(form.To) || + form.GiftID <= 0 || form.RevisionID <= 0 || form.ChargeStars <= 0 || form.IssuedAt <= 0 || + form.ExpiresAt != form.IssuedAt+600 || len([]rune(form.Message)) > 128 { + return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftFormPurposeInvalid + } + for attempt := 0; attempt < 8; attempt++ { + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + return domain.StarGiftPurchaseForm{}, fmt.Errorf("generate star gift form id: %w", err) + } + form.FormID = int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff) + if form.FormID == 0 { + form.FormID = 1 + } + _, err := s.db.Exec(ctx, `INSERT INTO star_gift_purchase_forms(buyer_user_id,form_id,gift_id,revision_id, +recipient_peer_type,recipient_peer_id,include_upgrade,hide_name,message,charge_stars,issued_at,expires_at) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, form.BuyerUserID, form.FormID, form.GiftID, form.RevisionID, + string(form.To.Type), form.To.ID, form.IncludeUpgrade, form.HideName, form.Message, form.ChargeStars, form.IssuedAt, form.ExpiresAt) + if err == nil { + return form, nil + } + if !isUniqueViolation(err) { + return domain.StarGiftPurchaseForm{}, err + } + } + return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable +} + +func (s *StarGiftLifecycleStore) ValidateStarGiftPurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error { + if s == nil || s.db == nil { + return domain.ErrStarGiftUnavailable + } + return validateStarGiftPurchaseForm(ctx, s.db, req, false) +} + +func validateStarGiftPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarGiftPurchaseRequest, lock bool) error { + if req.BuyerUserID <= 0 || req.FormID == 0 || req.Date <= 0 { + return domain.ErrStarGiftFormExpired + } + query := `SELECT gift_id,revision_id,recipient_peer_type,recipient_peer_id,include_upgrade,hide_name,message, +charge_stars,issued_at,expires_at FROM star_gift_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2` + if lock { + query += ` FOR UPDATE` + } + var form domain.StarGiftPurchaseForm + var peerType string + err := db.QueryRow(ctx, query, req.BuyerUserID, req.FormID).Scan(&form.GiftID, &form.RevisionID, &peerType, &form.To.ID, + &form.IncludeUpgrade, &form.HideName, &form.Message, &form.ChargeStars, &form.IssuedAt, &form.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrStarGiftFormExpired + } + if err != nil { + return err + } + form.FormID, form.BuyerUserID, form.To.Type = req.FormID, req.BuyerUserID, domain.PeerType(peerType) + if form.ExpiresAt < req.Date { + return domain.ErrStarGiftFormExpired + } + if form.To != req.To || form.GiftID != req.GiftID || form.IncludeUpgrade != req.IncludeUpgrade || + form.HideName != req.HideName || form.Message != req.Message { + return domain.ErrStarGiftFormPurposeInvalid + } + if form.RevisionID != req.RevisionID || form.ChargeStars != req.ChargeStars { + return domain.ErrStarGiftFormAmountMismatch + } + return nil +} + +func (s *StarGiftLifecycleStore) PurchaseStarGift(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) { + req.CommandKey = strings.TrimSpace(req.CommandKey) + if s == nil || s.db == nil || req.BuyerUserID <= 0 || !validLifecyclePeer(req.To) || req.GiftID <= 0 || + req.FormID == 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.Date <= 0 || len([]rune(req.Message)) > 128 { + return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftInvalid + } + if replay, found, err := s.loadStarGiftPurchaseReplay(ctx, req, domain.SendPrivateTextResult{}); err != nil || found { + return replay, err + } + if err := s.ValidateStarGiftPurchaseForm(ctx, req); err != nil { + return domain.StarGiftPurchaseResult{}, err + } + if req.To.Type == domain.PeerTypeChannel { + return s.purchaseStarGiftToChannel(ctx, req) + } + if s.messages == nil { + return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable + } + fingerprint := starGiftPurchaseFingerprint(req) + messageReq := domain.SendPrivateTextRequest{SenderUserID: req.BuyerUserID, RecipientUserID: req.To.ID, + RandomID: lifecycleCommandRandomID("purchase", req.BuyerUserID, req.CommandKey), Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.BuyerUserID, + RecipientBlocked: req.RecipientBlocked, IdempotencyFingerprint: fingerprint[:], + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{Saved: true}}}} + var result domain.StarGiftPurchaseResult + hooks := privateSendTxHooks{ + before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { + if err := validateStarGiftPurchaseForm(ctx, tx, req, true); err != nil { + return err + } + gift, saved, balance, err := s.prepareStarGiftPurchase(ctx, tx, req) + if err != nil { + return err + } + sticker := gift.Sticker + send.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{GiftID: gift.ID, + Stars: gift.Stars, ConvertStars: saved.ConvertStars, Title: gift.Title, Sticker: &sticker, Message: req.Message, + FromUserID: req.BuyerUserID, PeerUserID: req.To.ID, To: req.To, NameHidden: req.HideName, Saved: true, + CanUpgrade: gift.UpgradeStars > 0, PrepaidUpgrade: saved.PrepaidUpgradeStars > 0, + PrepaidUpgradeHash: saved.PrepaidUpgradeHash, UpgradePriceStars: gift.UpgradeStars, + UpgradeStars: saved.PrepaidUpgradeStars}}} + result.Gift, result.Saved, result.Balance = gift, saved, balance + return nil + }, + after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + msgID := sent.RecipientMessage.ID + if msgID <= 0 { + msgID = sent.SenderMessage.ID + } + result.Saved.MsgID = msgID + id, err := NewStarGiftStore(tx).Create(ctx, result.Saved) + if err != nil { + return err + } + result.Saved.ID = id + return s.insertStarGiftPurchaseCommand(ctx, tx, req, result.Saved.ID, result.Gift.Stars+result.Saved.PrepaidUpgradeStars, result.Balance.Balance) + }, + } + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + if isUniqueViolation(err) { + if replay, found, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, sent); replayErr != nil || found { + return replay, replayErr + } + } + return domain.StarGiftPurchaseResult{}, err + } + result.Send, result.Duplicate = sent, sent.Duplicate + if sent.Duplicate { + replay, _, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, sent) + return replay, replayErr + } + return result, nil +} + +func (s *StarGiftLifecycleStore) purchaseStarGiftToChannel(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) { + var result domain.StarGiftPurchaseResult + err := withTx(ctx, s.db, "purchase star gift for channel", func(tx pgx.Tx) error { + if err := validateStarGiftPurchaseForm(ctx, tx, req, true); err != nil { + return err + } + gift, saved, balance, err := s.prepareStarGiftPurchase(ctx, tx, req) + if err != nil { + return err + } + id, err := NewStarGiftStore(tx).Create(ctx, saved) + if err != nil { + return err + } + saved.ID, saved.SavedID = id, id + sticker := gift.Sticker + action := domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: &domain.MessageStarGiftAction{ + GiftID: gift.ID, Stars: gift.Stars, ConvertStars: saved.ConvertStars, Title: gift.Title, + Sticker: &sticker, Message: saved.Message, FromUserID: req.BuyerUserID, PeerChannelID: req.To.ID, + SavedID: id, NameHidden: saved.NameHidden, Saved: true, CanUpgrade: gift.UpgradeStars > 0, + PrepaidUpgrade: saved.PrepaidUpgradeStars > 0, PrepaidUpgradeHash: saved.PrepaidUpgradeHash, + UpgradePriceStars: gift.UpgradeStars, UpgradeStars: saved.PrepaidUpgradeStars, + }} + if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.BuyerUserID, id, req.Date, action); err != nil { + return err + } + if err := s.insertStarGiftPurchaseCommand(ctx, tx, req, id, gift.Stars+saved.PrepaidUpgradeStars, balance.Balance); err != nil { + return err + } + result = domain.StarGiftPurchaseResult{Gift: gift, Saved: saved, Balance: balance} + return nil + }) + if err != nil { + if isUniqueViolation(err) { + if replay, found, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, domain.SendPrivateTextResult{}); replayErr != nil || found { + return replay, replayErr + } + } + return domain.StarGiftPurchaseResult{}, err + } + return result, nil +} + +func (s *StarGiftLifecycleStore) prepareStarGiftPurchase(ctx context.Context, tx pgx.Tx, req domain.StarGiftPurchaseRequest) (domain.StarGift, domain.SavedStarGift, domain.StarsBalance, error) { + var revisionID int64 + var enabled bool + var remains int + if err := tx.QueryRow(ctx, `SELECT active_revision_id,enabled,availability_remains FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, req.GiftID). + Scan(&revisionID, &enabled, &remains); err != nil { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftInvalid + } + gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, revisionID) + if err != nil || !found || !enabled || gift.ID != req.GiftID || gift.SoldOut || gift.Auction || gift.LockedUntilDate > req.Date || + gift.Limited && remains <= 0 { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftInvalid + } + if gift.RevisionID != req.RevisionID { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftFormAmountMismatch + } + if gift.RequirePremium && !req.BuyerPremium { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrPremiumRequired + } + gift.AvailabilityRemains = remains + upgradePrice := int64(0) + prepayHash := "" + if gift.UpgradeStars > 0 || req.IncludeUpgrade { + revision, err := lockActiveCollectibleRevision(ctx, tx, gift.ID) + if err != nil || revision.Issued >= revision.SupplyTotal { + if req.IncludeUpgrade { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftCollectibleUnavailable + } + } else if req.IncludeUpgrade { + upgradePrice = revision.UpgradeStars + } else { + var token [32]byte + if _, err := rand.Read(token[:]); err != nil { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err + } + prepayHash = base64.RawURLEncoding.EncodeToString(token[:]) + } + } + if req.IncludeUpgrade && upgradePrice <= 0 { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftCollectibleUnavailable + } + if gift.Stars+upgradePrice != req.ChargeStars { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftFormAmountMismatch + } + var purchased int + if err := tx.QueryRow(ctx, `INSERT INTO star_gift_user_purchases(user_id,gift_id,purchased_count) VALUES($1,$2,1) +ON CONFLICT(user_id,gift_id) DO UPDATE SET purchased_count=star_gift_user_purchases.purchased_count+1,updated_at=now() +WHERE NOT $3 OR star_gift_user_purchases.purchased_count<$4 RETURNING purchased_count`, req.BuyerUserID, gift.ID, + gift.LimitedPerUser, gift.PerUserTotal).Scan(&purchased); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftUnavailable + } + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err + } + if gift.Limited { + if tag, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET availability_remains=availability_remains-1, +first_sale_date=CASE WHEN first_sale_date=0 THEN $2 ELSE first_sale_date END,last_sale_date=$2,updated_at=now() +WHERE gift_id=$1 AND availability_remains>0`, gift.ID, req.Date); err != nil || tag.RowsAffected() != 1 { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftUnavailable + } + } else if _, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET first_sale_date=CASE WHEN first_sale_date=0 THEN $2 ELSE first_sale_date END, +last_sale_date=$2,updated_at=now() WHERE gift_id=$1`, gift.ID, req.Date); err != nil { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err + } + charge := gift.Stars + upgradePrice + balance, err := s.debitLifecycleAmount(ctx, tx, req.BuyerUserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: charge}, domain.StarsReasonGift, + req.To, req.Date, "Star gift") + if err != nil { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err + } + saved := domain.SavedStarGift{Owner: req.To, FromUserID: req.BuyerUserID, GiftID: gift.ID, RevisionID: gift.RevisionID, + Date: req.Date, NameHidden: req.HideName, ConvertStars: gift.ConvertStars, PrepaidUpgradeStars: upgradePrice, + PrepaidUpgradeHash: prepayHash, Message: req.Message} + return gift, saved, balance, nil +} + +func (s *StarGiftLifecycleStore) insertStarGiftPurchaseCommand(ctx context.Context, tx pgx.Tx, req domain.StarGiftPurchaseRequest, savedID, charge, balance int64) error { + _, err := tx.Exec(ctx, `INSERT INTO star_gift_purchase_commands(buyer_user_id,command_key,gift_id,recipient_peer_type, +recipient_peer_id,saved_gift_id,form_id,charge_stars,balance_after,created_at) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.BuyerUserID, req.CommandKey, req.GiftID, string(req.To.Type), req.To.ID, + savedID, req.FormID, charge, balance, req.Date) + return err +} + +func (s *StarGiftLifecycleStore) loadStarGiftPurchaseReplay(ctx context.Context, req domain.StarGiftPurchaseRequest, sent domain.SendPrivateTextResult) (domain.StarGiftPurchaseResult, bool, error) { + var giftID, recipientID, savedID, formID, charge, balance int64 + var recipientType string + err := s.db.QueryRow(ctx, `SELECT gift_id,recipient_peer_type,recipient_peer_id,saved_gift_id,form_id,charge_stars,balance_after +FROM star_gift_purchase_commands WHERE buyer_user_id=$1 AND command_key=$2`, req.BuyerUserID, req.CommandKey). + Scan(&giftID, &recipientType, &recipientID, &savedID, &formID, &charge, &balance) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftPurchaseResult{}, false, nil + } + if err != nil { + return domain.StarGiftPurchaseResult{}, false, err + } + if giftID != req.GiftID || recipientType != string(req.To.Type) || recipientID != req.To.ID || formID != req.FormID || charge <= 0 { + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid + } + saved, found, err := savedStarGiftByID(ctx, s.db, savedID) + if err != nil || !found { + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid + } + if saved.Owner != req.To || saved.GiftID != req.GiftID || saved.NameHidden != req.HideName || saved.Message != req.Message || + (saved.PrepaidUpgradeStars > 0) != req.IncludeUpgrade { + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid + } + gift, found, err := NewStarGiftStore(s.db).CatalogRevision(ctx, saved.RevisionID) + if err != nil || !found { + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid + } + if req.To.Type == domain.PeerTypeUser && sent.SenderMessage.ID == 0 { + if s.messages == nil { + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftUnavailable + } + fingerprint := starGiftPurchaseFingerprint(req) + replay, replayFound, replayErr := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{ + SenderUserID: req.BuyerUserID, RecipientUserID: req.To.ID, + RandomID: lifecycleCommandRandomID("purchase", req.BuyerUserID, req.CommandKey), IdempotencyFingerprint: fingerprint[:], + }) + if replayErr != nil || !replayFound { + if replayErr != nil { + return domain.StarGiftPurchaseResult{}, false, replayErr + } + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid + } + sent = replay + } + return domain.StarGiftPurchaseResult{Gift: gift, Saved: saved, Balance: domain.StarsBalance{UserID: req.BuyerUserID, Balance: balance}, + Send: sent, Duplicate: true}, true, nil +} + +func starGiftPurchaseFingerprint(req domain.StarGiftPurchaseRequest) [32]byte { + return sha256.Sum256([]byte(fmt.Sprintf("telesrv:star-gift-purchase:v1:%d:%s:%d:%d:%t:%t:%s", + req.BuyerUserID, req.To.Type, req.To.ID, req.GiftID, req.IncludeUpgrade, req.HideName, req.Message))) +} diff --git a/internal/store/postgres/star_gift_upgrade.go b/internal/store/postgres/star_gift_upgrade.go index 3bf3dd14..d7cfe383 100644 --- a/internal/store/postgres/star_gift_upgrade.go +++ b/internal/store/postgres/star_gift_upgrade.go @@ -21,18 +21,38 @@ import ( // upgrades. It intentionally shares MessageStore's allocator and transaction // machinery so Stars, issuance, the saved gift and durable updates commit once. type StarGiftUpgradeStore struct { - db sqlcgen.DBTX - messages *MessageStore + db sqlcgen.DBTX + messages *MessageStore + lifecycle domain.StarGiftLifecyclePolicy } -func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore) *StarGiftUpgradeStore { - return &StarGiftUpgradeStore{db: db, messages: messages} +type StarGiftUpgradeOption func(*StarGiftUpgradeStore) + +func WithStarGiftLifecyclePolicy(policy domain.StarGiftLifecyclePolicy) StarGiftUpgradeOption { + return func(s *StarGiftUpgradeStore) { + if policy.Valid() { + s.lifecycle = policy + } + } +} + +func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore, opts ...StarGiftUpgradeOption) *StarGiftUpgradeStore { + s := &StarGiftUpgradeStore{db: db, messages: messages, lifecycle: domain.StarGiftLifecyclePolicy{ + TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 250, + }} + for _, opt := range opts { + opt(s) + } + return s } func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) { if s == nil || s.db == nil || s.messages == nil || req.UserID <= 0 || !req.Ref.Valid() || - req.Ref.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) || - req.ChargeStars < 0 || req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 { + (req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) || + (req.Ref.Owner.Type != domain.PeerTypeUser && req.Ref.Owner.Type != domain.PeerTypeChannel) || + req.ChargeStars < 0 || (req.RequirePrepaid && (req.ChargeStars != 0 || req.FormID != 0)) || + (!req.RequirePrepaid && (req.ChargeStars <= 0 || req.FormID == 0)) || + req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 { return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid } saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref) @@ -48,7 +68,11 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S "telesrv:star-gift-upgrade:v1:%s:%d:%d:%t:%d:%t", commandKey, saved.ID, req.ChargeStars, req.RequirePrepaid, req.FormID, req.KeepOriginalDetails, ))) - randomID := starGiftUpgradeRandomID(saved.FromUserID, req.UserID, commandKey) + messageSenderID := saved.FromUserID + if saved.Owner.Type == domain.PeerTypeChannel { + messageSenderID = domain.OfficialSystemUserID + } + randomID := starGiftUpgradeRandomID(messageSenderID, req.UserID, commandKey) placeholder := &domain.MessageMedia{ Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ @@ -57,7 +81,7 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S }, } messageReq := domain.SendPrivateTextRequest{ - SenderUserID: saved.FromUserID, + SenderUserID: messageSenderID, RecipientUserID: req.UserID, RandomID: randomID, Media: placeholder, @@ -89,6 +113,19 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S if err != nil { return err } + var craftable bool + if err := tx.QueryRow(ctx, `SELECT EXISTS ( +SELECT 1 FROM star_gift_collectible_models +WHERE collectible_revision_id=$1 AND crafted +)`, revision.ID).Scan(&craftable); err != nil { + return fmt.Errorf("load collectible craft capability: %w", err) + } + craftChancePermille := 0 + canCraftAt := 0 + if craftable { + craftChancePermille = s.lifecycle.CraftChancePermille + canCraftAt = starGiftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds) + } if revision.Issued >= revision.SupplyTotal { return domain.ErrStarGiftCollectibleSoldOut } @@ -134,10 +171,12 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S INSERT INTO unique_star_gifts (id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num, owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id, - backdrop_attribute_id, keep_original_details) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + backdrop_attribute_id, keep_original_details, original_owner_peer_type, original_owner_peer_id, + craft_chance_permille, offer_min_stars) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, uniqueID, locked.GiftID, revision.ID, locked.ID, title, slug, num, - string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails); err != nil { + string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails, + string(locked.Owner.Type), locked.Owner.ID, craftChancePermille, s.lifecycle.OfferMinStars); err != nil { return fmt.Errorf("insert unique star gift: %w", err) } if _, err := tx.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued+1 WHERE id=$1`, revision.ID); err != nil { @@ -145,14 +184,21 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, } if _, err := tx.Exec(ctx, ` UPDATE peer_star_gifts -SET unique_gift_id=$2, prepaid_upgrade_stars=0, convert_stars=0 -WHERE id=$1 AND unique_gift_id IS NULL AND NOT converted`, locked.ID, uniqueID); err != nil { +SET unique_gift_id=$2, prepaid_upgrade_stars=0, prepaid_upgrade_hash='', convert_stars=0, + transfer_stars=$3,can_export_at=$4,can_transfer_at=$5,can_resell_at=$6, + drop_original_details_stars=$7,can_craft_at=$8 +WHERE id=$1 AND unique_gift_id IS NULL AND lifecycle_status='active'`, locked.ID, uniqueID, + s.lifecycle.TransferStars, starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds), + starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds), starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds), + s.lifecycle.DropOriginalDetailsStars, canCraftAt); err != nil { return fmt.Errorf("upgrade saved star gift: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO star_gift_upgrade_commands - (user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after) -VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance); err != nil { + (user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after, + charge_stars, require_prepaid, keep_original_details) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance, + req.ChargeStars, req.RequirePrepaid, req.KeepOriginalDetails); err != nil { return fmt.Errorf("insert star gift upgrade command: %w", err) } @@ -166,21 +212,20 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq locked.UniqueGiftID = uniqueID locked.PrepaidUpgradeStars = 0 locked.ConvertStars = 0 + locked.TransferStars = s.lifecycle.TransferStars + locked.CanExportAt = starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds) + locked.CanTransferAt = starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds) + locked.CanResellAt = starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds) + locked.DropOriginalDetailsStars = s.lifecycle.DropOriginalDetailsStars + locked.CanCraftAt = canCraftAt locked.Unique = &unique result.Saved, result.Unique, result.Balance = locked, unique, balance + action := starGiftUpgradeUniqueAction(locked, unique, req, messageSenderID) messageReq.Media = &domain.MessageMedia{ Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ - Kind: domain.MessageServiceActionStarGiftUnique, - StarGiftUnique: &domain.MessageStarGiftUniqueAction{ - Gift: unique, FromUserID: func() int64 { - if locked.NameHidden { - return 0 - } - return locked.FromUserID - }(), Peer: locked.Owner, Upgrade: true, Saved: !locked.Unsaved, - PrepaidUpgrade: req.RequirePrepaid, - }, + Kind: domain.MessageServiceActionStarGiftUnique, + StarGiftUnique: action, }, } return nil @@ -201,6 +246,40 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq return fmt.Errorf("save star gift upgrade message id lost aggregate row") } result.Saved.UpgradeMsgID = ownerMessageID + if result.Saved.Owner.Type == domain.PeerTypeUser { + edits, err := s.markPrivateStarGiftSourceUpgradedTx(ctx, tx, req, result.Saved, sent) + if err != nil { + return err + } + result.SourceEdits = edits + ownerEditPts := 0 + for _, edit := range edits { + if edit.UserID == req.UserID { + ownerEditPts = edit.Event.Pts + break + } + } + if ownerEditPts <= 0 { + return fmt.Errorf("upgrade source edit missing owner event") + } + tag, err := tx.Exec(ctx, ` +UPDATE star_gift_upgrade_commands SET source_edit_pts=$3 +WHERE user_id=$1 AND command_key=$2`, req.UserID, commandKey, ownerEditPts) + if err != nil { + return fmt.Errorf("save star gift source edit pts: %w", err) + } + if tag.RowsAffected() != 1 { + return fmt.Errorf("save star gift source edit pts lost command row") + } + } else { + action := starGiftUpgradeUniqueAction(result.Saved, result.Unique, req, messageSenderID) + if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, result.Saved.Owner.ID, + req.UserID, result.Saved.SavedID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionStarGiftUnique, StarGiftUnique: action, + }); err != nil { + return fmt.Errorf("append channel star gift upgrade admin log: %w", err) + } + } return nil }, } @@ -216,11 +295,186 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq return result, nil } +func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.UniqueStarGift, req domain.StarGiftUpgradeRequest, messageSenderID int64) *domain.MessageStarGiftUniqueAction { + fromUserID := saved.FromUserID + if saved.NameHidden { + fromUserID = 0 + } + if saved.Owner.Type == domain.PeerTypeChannel { + // TDesktop recognizes a channel-owned upgrade from the official service + // peer plus action.peer=channel and action.saved_id. + fromUserID = messageSenderID + } + savedID := saved.SavedID + if saved.Owner.Type == domain.PeerTypeUser { + // For user-owned gifts messageActionStarGiftUnique.saved_id is the + // stable source gift message id. TDesktop uses this back-reference as + // inputSavedStarGiftUser.msg_id for crafting and later lifecycle RPCs. + savedID = int64(saved.MsgID) + } + return &domain.MessageStarGiftUniqueAction{ + Gift: unique, FromUserID: fromUserID, Peer: saved.Owner, SavedID: savedID, + Upgrade: true, Saved: !saved.Unsaved, PrepaidUpgrade: req.RequirePrepaid, + CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, + CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt, + DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt, + } +} + +// markPrivateStarGiftSourceUpgradedTx rewrites both visible copies of the +// original gift service message in the same transaction that creates the +// unique gift message. upgrade_msg_id is box-local, so each owner projection +// must point at that owner's copy of the new service message. Every rewrite is +// a durable edit_message event with its own pts and outbox row. +func (s *StarGiftUpgradeStore) markPrivateStarGiftSourceUpgradedTx( + ctx context.Context, + tx pgx.Tx, + req domain.StarGiftUpgradeRequest, + saved domain.SavedStarGift, + sent domain.SendPrivateTextResult, +) ([]domain.EditedMessageForUser, error) { + if saved.Owner.Type != domain.PeerTypeUser || saved.Owner.ID != req.UserID || saved.MsgID <= 0 { + return nil, domain.ErrStarGiftCollectibleInvalid + } + q := sqlcgen.New(tx) + target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{ + OwnerUserID: req.UserID, + BoxID: int32(saved.MsgID), + PeerType: string(domain.PeerTypeUser), + PeerID: saved.FromUserID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrStarGiftCollectibleInvalid + } + return nil, fmt.Errorf("lock star gift source message: %w", err) + } + boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: privateMessageOwnerIDs(req.UserID, saved.FromUserID), + MessageSenderID: target.MessageSenderID, + PrivateMessageID: target.PrivateMessageID, + }) + if err != nil { + return nil, fmt.Errorf("list star gift source message boxes: %w", err) + } + if len(boxes) == 0 { + return nil, domain.ErrStarGiftCollectibleInvalid + } + upgradeMessageIDs := make(map[int64]int, 2) + if sent.SenderMessage.OwnerUserID > 0 && sent.SenderMessage.ID > 0 { + upgradeMessageIDs[sent.SenderMessage.OwnerUserID] = sent.SenderMessage.ID + } + if sent.RecipientMessage.OwnerUserID > 0 && sent.RecipientMessage.ID > 0 { + upgradeMessageIDs[sent.RecipientMessage.OwnerUserID] = sent.RecipientMessage.ID + } + edits := make([]domain.EditedMessageForUser, 0, len(boxes)) + var privateMediaJSON []byte + for _, box := range boxes { + upgradeMessageID := upgradeMessageIDs[box.OwnerUserID] + if upgradeMessageID <= 0 { + return nil, fmt.Errorf("upgrade service message missing box for user %d", box.OwnerUserID) + } + media, err := decodeMessageMedia(box.MediaJson) + if err != nil { + return nil, fmt.Errorf("decode star gift source media: %w", err) + } + if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil || + media.ServiceAction.Kind != domain.MessageServiceActionStarGift || media.ServiceAction.StarGift == nil { + return nil, fmt.Errorf("star gift source message %d has invalid media", box.BoxID) + } + action := media.ServiceAction.StarGift + if action.UpgradeMsgID != 0 && action.UpgradeMsgID != upgradeMessageID { + return nil, fmt.Errorf("star gift source message %d has conflicting upgrade message %d", box.BoxID, action.UpgradeMsgID) + } + action.UpgradeMsgID = upgradeMessageID + action.CanUpgrade = false + mediaJSON, err := encodeMessageMedia(media) + if err != nil { + return nil, fmt.Errorf("encode upgraded star gift source media: %w", err) + } + pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID) + if err != nil { + return nil, fmt.Errorf("allocate star gift source edit pts: %w", err) + } + tag, err := tx.Exec(ctx, ` +UPDATE message_boxes SET media=$3, pts=$4 +WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts)) + if err != nil { + return nil, fmt.Errorf("update star gift source message box: %w", err) + } + if tag.RowsAffected() != 1 { + return nil, fmt.Errorf("update star gift source message box lost row") + } + msg, err := messageFromVisibleBoxRow(box) + if err != nil { + return nil, err + } + msg.Media = media + msg.Pts = pts + if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil { + return nil, err + } + event := domain.UpdateEvent{ + UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage, + Pts: pts, PtsCount: 1, Date: req.Date, Message: msg, + } + if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil { + return nil, fmt.Errorf("append star gift source edit event: %w", err) + } + dispatchAuthKeyID := [8]byte{} + dispatchSessionID := int64(0) + if msg.OwnerUserID == req.UserID { + dispatchAuthKeyID = req.OriginAuthKeyID + dispatchSessionID = req.OriginSessionID + } + if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{ + TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage), + ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), ExcludeSessionID: dispatchSessionID, + }); err != nil { + return nil, fmt.Errorf("enqueue star gift source edit: %w", err) + } + if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 { + privateMediaJSON = mediaJSON + } + edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event}) + } + if len(privateMediaJSON) == 0 { + return nil, fmt.Errorf("upgrade source message missing private media projection") + } + if _, err := tx.Exec(ctx, ` +UPDATE private_messages SET media=$3 +WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil { + return nil, fmt.Errorf("update star gift source private message: %w", err) + } + return edits, nil +} + +func starGiftReadyAt(date, delaySeconds int) int { + if date <= 0 || delaySeconds <= 0 { + return 0 + } + const maxProtocolDate = int(1<<31 - 1) + if delaySeconds > maxProtocolDate-date { + return maxProtocolDate + } + return date + delaySeconds +} + func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) { where, args := savedStarGiftRefWhere(ref) + return lockSavedStarGiftWhere(ctx, tx, where, args...) +} + +func lockSavedStarGiftByID(ctx context.Context, tx pgx.Tx, savedID int64) (domain.SavedStarGift, error) { + return lockSavedStarGiftWhere(ctx, tx, "p.id = $1", savedID) +} + +func lockSavedStarGiftWhere(ctx context.Context, tx pgx.Tx, where string, args ...any) (domain.SavedStarGift, error) { row := tx.QueryRow(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, - p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, + p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num, + p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at, + p.drop_original_details_stars, p.can_craft_at, p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order, COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id) FROM star_gift_collection_items i @@ -283,7 +537,13 @@ func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64, } func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID int64) (int64, error) { - rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, table), revisionID) + extra := "" + if table == "star_gift_collectible_models" { + extra = " AND NOT crafted" + } + rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s +WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0%s +ORDER BY sort_order, id`, table, extra), revisionID) if err != nil { return 0, fmt.Errorf("list collectible attributes for issuance: %w", err) } @@ -305,7 +565,7 @@ func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, re if err := rows.Err(); err != nil { return 0, err } - if len(items) == 0 || total != 1000 { + if len(items) == 0 || total <= 0 { return 0, domain.ErrStarGiftCollectibleInvalid } draw, err := rand.Int(rand.Reader, big.NewInt(int64(total))) @@ -346,20 +606,94 @@ func (s *StarGiftUpgradeStore) loadUpgradeReplay(ctx context.Context, req domain } return domain.StarGiftUpgradeResult{}, err } - var commandUniqueID int64 - var balanceAfter int64 - if err := s.db.QueryRow(ctx, `SELECT unique_gift_id, balance_after FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&commandUniqueID, &balanceAfter); err != nil { + receipt, found, err := s.StarGiftUpgradeReceipt(ctx, req.UserID, req.CommandKey) + if err != nil { return domain.StarGiftUpgradeResult{}, fmt.Errorf("load star gift upgrade replay: %w", err) } - if commandUniqueID != unique.ID || saved.ID != original.ID { + if !found || receipt.UniqueGiftID != unique.ID || receipt.SourceSavedGiftID != saved.ID || saved.ID != original.ID || + receipt.FormID != req.FormID || receipt.ChargeStars != req.ChargeStars || receipt.RequirePrepaid != req.RequirePrepaid || + receipt.KeepOriginalDetails != req.KeepOriginalDetails { return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid } uniqueCopy := unique saved.Unique = &uniqueCopy + sourceEdits, err := s.loadUpgradeSourceReplay(ctx, req, saved, receipt.SourceEditPts) + if err != nil { + return domain.StarGiftUpgradeResult{}, err + } return domain.StarGiftUpgradeResult{ - Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: balanceAfter}, - Send: sent, Duplicate: true, + Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: receipt.BalanceAfter}, + Send: sent, SourceEdits: sourceEdits, Duplicate: true, }, nil } +func (s *StarGiftUpgradeStore) loadUpgradeSourceReplay(ctx context.Context, req domain.StarGiftUpgradeRequest, saved domain.SavedStarGift, pts int) ([]domain.EditedMessageForUser, error) { + if saved.Owner.Type != domain.PeerTypeUser { + return nil, nil + } + if pts <= 0 || saved.MsgID <= 0 { + return nil, domain.ErrStarGiftCollectibleInvalid + } + var privateMessageID, messageSenderID int64 + err := s.db.QueryRow(ctx, ` +SELECT private_message_id,message_sender_id FROM message_boxes +WHERE owner_user_id=$1 AND box_id=$2 AND peer_type='user' AND peer_id=$3 AND NOT deleted`, + req.UserID, saved.MsgID, saved.FromUserID).Scan(&privateMessageID, &messageSenderID) + if errors.Is(err, pgx.ErrNoRows) { + // A later delete event is authoritative; replaying the old edit here + // would transiently resurrect the source message. + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("load star gift source replay message: %w", err) + } + boxes, err := sqlcgen.New(s.db).ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: []int64{req.UserID}, MessageSenderID: messageSenderID, PrivateMessageID: privateMessageID, + }) + if err != nil { + return nil, fmt.Errorf("load star gift source replay box: %w", err) + } + if len(boxes) != 1 || int(boxes[0].BoxID) != saved.MsgID { + return nil, domain.ErrStarGiftCollectibleInvalid + } + var eventDate int + err = s.db.QueryRow(ctx, ` +SELECT date FROM user_update_events +WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, + req.UserID, pts, saved.MsgID).Scan(&eventDate) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrStarGiftCollectibleInvalid + } + return nil, fmt.Errorf("load star gift source replay event: %w", err) + } + msg, err := messageFromVisibleBoxRow(boxes[0]) + if err != nil { + return nil, err + } + msg.Pts = pts + event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage, Pts: pts, PtsCount: 1, Date: eventDate, Message: msg} + return []domain.EditedMessageForUser{{UserID: req.UserID, Message: msg, Event: event}}, nil +} + +func (s *StarGiftUpgradeStore) StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) { + commandKey = strings.TrimSpace(commandKey) + if s == nil || s.db == nil || userID <= 0 || commandKey == "" || len(commandKey) > 256 { + return domain.StarGiftUpgradeReceipt{}, false, nil + } + receipt := domain.StarGiftUpgradeReceipt{UserID: userID} + err := s.db.QueryRow(ctx, ` +SELECT source_saved_gift_id,form_id,unique_gift_id,charge_stars,balance_after,source_edit_pts,require_prepaid,keep_original_details +FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, userID, commandKey).Scan( + &receipt.SourceSavedGiftID, &receipt.FormID, &receipt.UniqueGiftID, &receipt.ChargeStars, + &receipt.BalanceAfter, &receipt.SourceEditPts, &receipt.RequirePrepaid, &receipt.KeepOriginalDetails) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftUpgradeReceipt{}, false, nil + } + if err != nil { + return domain.StarGiftUpgradeReceipt{}, false, err + } + return receipt, true, nil +} + var _ store.StarGiftUpgradeStore = (*StarGiftUpgradeStore)(nil) diff --git a/internal/store/star_gift.go b/internal/store/star_gift.go index 0fff4bfc..6e843f23 100644 --- a/internal/store/star_gift.go +++ b/internal/store/star_gift.go @@ -16,6 +16,9 @@ type StarGiftStore interface { CatalogRevision(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) // CreateCatalogRevision 创建新礼物或为既有礼物创建新版本,并原子切换当前版本。 CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) + // CreateCatalogBundle atomically switches the catalog revision and optional complete + // collectible revision. It is the only write path used by official full imports. + CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) // AnimationJSON 返回当前版本的规范化 Lottie JSON,供管理后台安全预览。 @@ -60,4 +63,44 @@ type StarGiftStore interface { // private service-message updates. type StarGiftUpgradeStore interface { UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) + StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) +} + +// StarGiftLifecycleStore owns transactions that span collectible ownership, listings, +// balances and service-message updates. Implementations must serialize on the saved/unique +// aggregate and return exact replays for command-key/random-id retries. +type StarGiftLifecycleStore interface { + IssueStarGiftPurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) + ValidateStarGiftPurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error + PurchaseStarGift(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) + ConvertStarGift(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) + ListResaleStarGifts(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) + UniqueStarGiftValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) + SetStarGiftListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) + TransferStarGift(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) + PurchaseResaleStarGift(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) + SendStarGiftOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) + ResolveStarGiftOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) + ListCraftStarGifts(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) + CraftStarGift(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) + StarGiftAuctionState(ctx context.Context, userID int64, giftID int64, slug string, now int) (domain.StarGiftAuction, error) + ActiveStarGiftAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) + StarGiftAuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) + BidStarGiftAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) + PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) + PrepayStarGiftUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) + DropStarGiftOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) + SetStarGiftNotifications(ctx context.Context, userID, channelID int64, enabled bool) error + RecordStarGiftWithdrawal(ctx context.Context, req domain.StarGiftWithdrawalRequest, provider, providerRequestID, url string, expiresAt int) (domain.StarGiftWithdrawal, error) + ResolveStarGiftWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) + CompleteStarGiftWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) + TonBalance(ctx context.Context, userID int64) (int64, error) + TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) + ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) + ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) + ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) + ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) + // SweepStarGiftLifecycle advances time-driven offer/auction aggregates and + // drains their durable notification/delivery outboxes in bounded batches. + SweepStarGiftLifecycle(ctx context.Context, now, limit int) error } diff --git a/internal/web/server.go b/internal/web/server.go index 9cbcad7f..632e3d22 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -21,16 +21,18 @@ import ( ) type Config struct { - Addr string - PublicBaseURL string - AppScheme string - WebBaseURL string - AppName string - StickerSets StickerSetResolver - Users UsernameResolver - Channels PublicChannelResolver - Privacy AnonymousPrivacyResolver - Photos ProfilePhotoResolver + Addr string + PublicBaseURL string + AppScheme string + WebBaseURL string + AppName string + StickerSets StickerSetResolver + Users UsernameResolver + Channels PublicChannelResolver + Privacy AnonymousPrivacyResolver + Photos ProfilePhotoResolver + UniqueGifts UniqueStarGiftResolver + GiftWithdrawals StarGiftWithdrawalResolver } type StickerSetResolver interface { @@ -57,6 +59,15 @@ type ProfilePhotoResolver interface { GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) } +type UniqueStarGiftResolver interface { + UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) +} + +type StarGiftWithdrawalResolver interface { + ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) + CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) +} + func Start(ctx context.Context, cfg Config, logger *zap.Logger) (*http.Server, error) { addr := strings.TrimSpace(cfg.Addr) if addr == "" { @@ -132,16 +143,18 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { logger = zap.NewNop() } h := &handler{ - stickerSets: cfg.StickerSets, - users: cfg.Users, - channels: cfg.Channels, - privacy: cfg.Privacy, - photos: cfg.Photos, - publicBaseURL: cfg.PublicBaseURL, - appScheme: cfg.AppScheme, - webBaseURL: cfg.WebBaseURL, - appName: cfg.AppName, - logger: logger, + stickerSets: cfg.StickerSets, + users: cfg.Users, + channels: cfg.Channels, + privacy: cfg.Privacy, + photos: cfg.Photos, + uniqueGifts: cfg.UniqueGifts, + giftWithdrawals: cfg.GiftWithdrawals, + publicBaseURL: cfg.PublicBaseURL, + appScheme: cfg.AppScheme, + webBaseURL: cfg.WebBaseURL, + appName: cfg.AppName, + logger: logger, } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", h.healthz) @@ -149,22 +162,87 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers) mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji) mux.HandleFunc("GET /addlist/{slug}", h.addList) + mux.HandleFunc("GET /nft/{slug}", h.uniqueGift) + mux.HandleFunc("GET /nft/{slug}/{$}", h.uniqueGift) + mux.HandleFunc("GET /gift-withdrawal/{requestID}", h.starGiftWithdrawal) + mux.HandleFunc("POST /gift-withdrawal/{requestID}", h.completeStarGiftWithdrawal) mux.HandleFunc("GET /{username}", h.usernameLink) mux.HandleFunc("GET /{username}/{$}", h.usernameLink) return publicSecurityHeaders(mux), nil } type handler struct { - stickerSets StickerSetResolver - users UsernameResolver - channels PublicChannelResolver - privacy AnonymousPrivacyResolver - photos ProfilePhotoResolver - publicBaseURL string - appScheme string - webBaseURL string - appName string - logger *zap.Logger + stickerSets StickerSetResolver + users UsernameResolver + channels PublicChannelResolver + privacy AnonymousPrivacyResolver + photos ProfilePhotoResolver + uniqueGifts UniqueStarGiftResolver + giftWithdrawals StarGiftWithdrawalResolver + publicBaseURL string + appScheme string + webBaseURL string + appName string + logger *zap.Logger +} + +type starGiftWithdrawalPage struct { + AppName string + Title string + Slug string + Status string + OwnerAddress string + GiftAddress string + ExpiresAt string + CanComplete bool +} + +var starGiftWithdrawalTemplate = template.Must(template.New("star-gift-withdrawal").Parse(` + +{{.Title}} · {{.AppName}}

{{.Title}}

Collectible: {{.Slug}}

+{{if .CanComplete}}

This export is handled only by {{.AppName}}'s internal ledger. No external blockchain or wallet is contacted.

Expires: {{.ExpiresAt}}

{{else}}

Status: {{.Status}}

{{if .OwnerAddress}}

Owner address: {{.OwnerAddress}}

Gift address: {{.GiftAddress}}

{{end}}{{end}} +
`)) + +func (h *handler) starGiftWithdrawal(w http.ResponseWriter, r *http.Request) { + h.renderStarGiftWithdrawal(w, r, false) +} + +func (h *handler) completeStarGiftWithdrawal(w http.ResponseWriter, r *http.Request) { + h.renderStarGiftWithdrawal(w, r, true) +} + +func (h *handler) renderStarGiftWithdrawal(w http.ResponseWriter, r *http.Request, complete bool) { + requestID := strings.TrimSpace(r.PathValue("requestID")) + if h.giftWithdrawals == nil || requestID == "" || len(requestID) > 256 { + http.NotFound(w, r) + return + } + var withdrawal domain.StarGiftWithdrawal + var found bool + var err error + if complete { + withdrawal, err = h.giftWithdrawals.CompleteWithdrawal(r.Context(), requestID, int(time.Now().Unix())) + found = err == nil + } else { + withdrawal, found, err = h.giftWithdrawals.ResolveWithdrawal(r.Context(), requestID) + } + if err != nil || !found { + http.NotFound(w, r) + return + } + page := starGiftWithdrawalPage{AppName: h.appName, Title: withdrawal.Gift.Title, Slug: withdrawal.Gift.Slug, + Status: withdrawal.Status, OwnerAddress: withdrawal.Gift.OwnerAddress, GiftAddress: withdrawal.Gift.GiftAddress, + ExpiresAt: time.Unix(int64(withdrawal.ExpiresAt), 0).UTC().Format(time.RFC3339), + CanComplete: withdrawal.Status == "pending" && withdrawal.ExpiresAt > int(time.Now().Unix())} + if page.Title == "" { + page.Title = "Collectible gift export" + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := starGiftWithdrawalTemplate.Execute(w, page); err != nil { + h.logger.Warn("render star gift withdrawal", zap.Error(err)) + } } func (h *handler) healthz(w http.ResponseWriter, _ *http.Request) { @@ -205,6 +283,63 @@ func (h *handler) addList(w http.ResponseWriter, r *http.Request) { } } +func (h *handler) uniqueGift(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if h.uniqueGifts == nil || !validStarGiftSlugPath(slug) { + http.NotFound(w, r) + return + } + unique, found, err := h.uniqueGifts.UniqueBySlug(r.Context(), slug) + if err != nil { + h.logger.Error("Public unique star gift lookup failed", zap.String("slug", slug), zap.Error(err)) + http.Error(w, "collectible gift lookup failed", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + canonicalSlug := unique.Slug + if unique.ID <= 0 || unique.GiftID <= 0 || unique.Num <= 0 || + !validStarGiftSlugPath(canonicalSlug) || !strings.EqualFold(slug, canonicalSlug) || + !utf8.ValidString(unique.Title) || utf8.RuneCountInString(unique.Title) > domain.MaxStarGiftTitleRunes { + h.logger.Error("Public unique star gift resolver returned invalid aggregate", + zap.String("requested_slug", slug), zap.String("resolved_slug", canonicalSlug), + zap.Int64("unique_id", unique.ID), zap.Int64("gift_id", unique.GiftID), zap.Int("num", unique.Num)) + http.Error(w, "collectible gift lookup failed", http.StatusInternalServerError) + return + } + if slug != canonicalSlug || strings.HasSuffix(r.URL.Path, "/") { + http.Redirect(w, r, h.publicURL("nft", canonicalSlug), http.StatusPermanentRedirect) + return + } + title := strings.TrimSpace(unique.Title) + if title == "" { + title = "Collectible gift" + } + subtitle := fmt.Sprintf("Collectible #%d", unique.Num) + if unique.AvailabilityIssued > 0 && unique.AvailabilityTotal >= unique.AvailabilityIssued { + subtitle += fmt.Sprintf(" · %s/%s issued", groupedDecimal(unique.AvailabilityIssued), groupedDecimal(unique.AvailabilityTotal)) + } + app := h.appURL("nft", "slug", canonicalSlug) + data := pageData{ + AppName: h.appName, + Title: title, + KindLabel: "collectible gift", + Subtitle: subtitle, + Description: "This collectible was created from a gift on " + h.appName + ". Open it in the app to view its current details.", + CanonicalURL: h.publicURL("nft", canonicalSlug), + AppURL: template.URL(app), + LegacyTgURL: template.URL(legacyTgURL("nft", "slug", canonicalSlug)), + } + data.AppURLJS = template.JS(strconv.Quote(app)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=60, must-revalidate") + if err := landingTemplate.Execute(w, data); err != nil { + h.logger.Error("Render public unique star gift page failed", zap.String("slug", canonicalSlug), zap.Error(err)) + } +} + func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.PathValue("username")) if !validUsernamePath(username) { @@ -734,7 +869,7 @@ func publicWebAppURL(webBaseURL, legacyURL string) string { func publicSecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") @@ -764,6 +899,23 @@ func validSlugPath(slug string) bool { return links.ValidChatlistSlug(slug) } +func validStarGiftSlugPath(slug string) bool { + if slug == "" || len(slug) > domain.MaxStarGiftSlugBytes { + return false + } + for _, r := range slug { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '.' || r == '_' || r == '-': + default: + return false + } + } + return true +} + func validUsernamePath(username string) bool { if username == "" || len(username) < 5 || len(username) > 32 { return false diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 055fca32..aca6a959 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "telesrv/internal/domain" ) @@ -44,6 +45,158 @@ func newTestHandlerWithPublicPeers( return h } +type fakeGiftWithdrawals struct { + value domain.StarGiftWithdrawal + found bool + completeCalls int +} + +type fakeUniqueGifts struct { + bySlug map[string]domain.UniqueStarGift + err error + calls int +} + +func (f *fakeUniqueGifts) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) { + f.calls++ + if f.err != nil { + return domain.UniqueStarGift{}, false, f.err + } + value, ok := f.bySlug[strings.ToLower(slug)] + return value, ok, nil +} + +func TestHandlerServesUniqueGiftLandingPage(t *testing.T) { + const slug = "official-5895603153683874485-7" + resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ + slug: { + ID: 7001, GiftID: 5895603153683874485, Title: "Official Gift", Slug: slug, Num: 7, + AvailabilityIssued: 7, AvailabilityTotal: 1000, + }, + }} + handler, err := NewHandler(Config{ + StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "http://127.0.0.1:2401", + }) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/"+slug, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + for _, want := range []string{ + "Official Gift", "Collectible #7", "7/1 000 issued", + "http://127.0.0.1:2401/nft/" + slug, + "telesrv://nft?slug=" + slug, + "tg://nft?slug=" + slug, + "Open it in the app to view its current details.", + } { + if !strings.Contains(rr.Body.String(), want) { + t.Fatalf("body missing %q:\n%s", want, rr.Body.String()) + } + } + if strings.Contains(rr.Body.String(), `window.location.href = "tg://`) { + t.Fatalf("landing page must not auto-open tg:// and steal official Telegram:\n%s", rr.Body.String()) + } + if got := rr.Header().Get("Cache-Control"); got != "public, max-age=60, must-revalidate" { + t.Fatalf("Cache-Control = %q", got) + } +} + +func TestHandlerCanonicalizesUniqueGiftSlug(t *testing.T) { + const canonical = "Official-Gift-7" + resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ + strings.ToLower(canonical): {ID: 7, GiftID: 70, Slug: canonical, Num: 7}, + }} + handler, err := NewHandler(Config{ + StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "https://telesrv.net", + }) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + for _, path := range []string{"/nft/official-gift-7", "/nft/" + canonical + "/"} { + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusPermanentRedirect || rr.Header().Get("Location") != "https://telesrv.net/nft/"+canonical { + t.Fatalf("%s status=%d location=%q", path, rr.Code, rr.Header().Get("Location")) + } + } +} + +func TestHandlerRejectsInvalidMissingAndBrokenUniqueGift(t *testing.T) { + resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ + "broken-1": {ID: 1, GiftID: 2, Slug: "other-1", Num: 1}, + }} + handler, err := NewHandler(Config{ + StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "https://telesrv.net", + }) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + for _, path := range []string{ + "/nft/missing-1", "/nft/bad!slug", "/nft/%E4%B8%AD%E6%96%87", "/nft/" + strings.Repeat("x", domain.MaxStarGiftSlugBytes+1), + } { + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("%s status=%d, want 404", path, rr.Code) + } + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/broken-1", nil)) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("broken aggregate status=%d, want 500", rr.Code) + } + + resolver.err = errors.New("lookup failed") + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/error-1", nil)) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("lookup error status=%d, want 500", rr.Code) + } +} + +func (f *fakeGiftWithdrawals) ResolveWithdrawal(context.Context, string) (domain.StarGiftWithdrawal, bool, error) { + return f.value, f.found, nil +} + +func (f *fakeGiftWithdrawals) CompleteWithdrawal(_ context.Context, _ string, _ int) (domain.StarGiftWithdrawal, error) { + f.completeCalls++ + f.value.Status = "completed" + f.value.Gift.OwnerAddress = "telesrv-owner:test" + f.value.Gift.GiftAddress = "telesrv-gift:test" + return f.value, nil +} + +func TestHandlerCompletesLocalStarGiftWithdrawal(t *testing.T) { + resolver := &fakeGiftWithdrawals{found: true, value: domain.StarGiftWithdrawal{ + ProviderRequestID: "safe-token", Status: "pending", ExpiresAt: int(time.Now().Add(time.Minute).Unix()), + Gift: domain.UniqueStarGift{Title: ``, Slug: "gift-1"}, + }} + handler, err := NewHandler(Config{StickerSets: fakeResolver{}, GiftWithdrawals: resolver, + PublicBaseURL: "https://telesrv.net", AppName: "telesrv"}) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/gift-withdrawal/safe-token", nil)) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Complete local export") || + strings.Contains(rr.Body.String(), ``) { + t.Fatalf("withdrawal GET status=%d body=%s", rr.Code, rr.Body.String()) + } + if csp := rr.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "form-action 'self'") { + t.Fatalf("withdrawal CSP does not allow its same-origin POST form: %q", csp) + } + + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/gift-withdrawal/safe-token", strings.NewReader(""))) + if rr.Code != http.StatusOK || resolver.completeCalls != 1 || !strings.Contains(rr.Body.String(), "Status: completed") || + !strings.Contains(rr.Body.String(), "telesrv-owner:test") || !strings.Contains(rr.Body.String(), "telesrv-gift:test") { + t.Fatalf("withdrawal POST calls=%d status=%d body=%s", resolver.completeCalls, rr.Code, rr.Body.String()) + } +} + func TestHandlerServesStickerSetLandingPage(t *testing.T) { resolver := fakeResolver{ "fresh_pack": { @@ -185,7 +338,10 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) { "stickers_pack": {ShortName: "stickers_pack", Title: "Stickers", Kind: domain.StickerSetKindStickers}, "emoji_pack": {ShortName: "emoji_pack", Title: "Emoji", Kind: domain.StickerSetKindEmoji, Emojis: true}, }, - Users: fakeUsers{"alice": {ID: 2001, Username: "Alice", FirstName: "Alice"}}, + Users: fakeUsers{"alice": {ID: 2001, Username: "Alice", FirstName: "Alice"}}, + UniqueGifts: &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ + "gift-1": {ID: 1, GiftID: 10, Slug: "gift-1", Num: 1}, + }}, PublicBaseURL: "https://links.example.test", AppScheme: "example-chat", WebBaseURL: "https://web.example.test/client/", @@ -220,6 +376,7 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) { {path: "/addstickers/stickers_pack", want: "example-chat://addstickers?set=stickers_pack"}, {path: "/addemoji/emoji_pack", want: "example-chat://addemoji?set=emoji_pack"}, {path: "/addlist/shared-folder", want: "example-chat://addlist?slug=shared-folder"}, + {path: "/nft/gift-1", want: "example-chat://nft?slug=gift-1"}, } { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil))