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.
This commit is contained in:
A 2026-07-19 03:11:35 +08:00
parent f2c2fd0236
commit 14bf7d1e20
92 changed files with 14768 additions and 727 deletions

View file

@ -132,6 +132,23 @@ TELESRV_MAPBOX_TOKEN=
TELESRV_MAPTILE_CACHE_DIR=data/maptiles TELESRV_MAPTILE_CACHE_DIR=data/maptiles
TELESRV_LANGPACK_SEED_DIR=data/langpack 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_BLOB_DIR=data/blobs
TELESRV_STICKER_SEED_DIR=data/sticker-seed TELESRV_STICKER_SEED_DIR=data/sticker-seed

1042
cmd/giftfetch/main.go Normal file

File diff suppressed because it is too large Load diff

238
cmd/giftfetch/main_test.go Normal file
View file

@ -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"},
},
}
}

View file

@ -134,23 +134,23 @@ type ChannelDetail struct {
} }
type StarGiftRow struct { type StarGiftRow struct {
GiftID int64 GiftID int64 `json:"GiftID,string"`
RevisionID int64 RevisionID int64 `json:"RevisionID,string"`
Revision int Revision int
Title string Title string
Stars int64 Stars int64 `json:"Stars,string"`
ConvertStars int64 ConvertStars int64 `json:"ConvertStars,string"`
Enabled bool Enabled bool
SortOrder int SortOrder int
DocumentID int64 DocumentID int64 `json:"DocumentID,string"`
SourceName string SourceName string
SourceFormat string SourceFormat string
AnimationSHA string AnimationSHA string
AnimationSize int64 AnimationSize int64 `json:"AnimationSize,string"`
Width int Width int
Height int Height int
FrameRate float64 FrameRate float64
ReceivedCount int64 ReceivedCount int64 `json:"ReceivedCount,string"`
CreatedBy string CreatedBy string
UpdatedAt time.Time UpdatedAt time.Time
} }

View file

@ -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", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI))) 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/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}/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", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI))) 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-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/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-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/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-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI))) 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) 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) { func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_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"` CommandID string `json:"command_id"`
Reason string `json:"reason"` Reason string `json:"reason"`
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"` GiftID int64 `json:"gift_id,string"`
Title string `json:"title"` Title string `json:"title"`
Stars int64 `json:"stars"` Stars int64 `json:"stars,string"`
ConvertStars int64 `json:"convert_stars"` ConvertStars int64 `json:"convert_stars,string"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
} }
@ -746,11 +762,48 @@ func (s *server) handleImportStarGiftAPI(w http.ResponseWriter, r *http.Request)
writeCommandResultAPI(w, result, err) 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 { type publishStarGiftCollectiblesAPIRequest struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
Reason string `json:"reason"` Reason string `json:"reason"`
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
UpgradeStars int64 `json:"upgrade_stars"` UpgradeStars int64 `json:"upgrade_stars,string"`
SupplyTotal int `json:"supply_total"` SupplyTotal int `json:"supply_total"`
SlugPrefix string `json:"slug_prefix"` SlugPrefix string `json:"slug_prefix"`
Models []admin.StarGiftCollectibleAnimationUpload `json:"models"` Models []admin.StarGiftCollectibleAnimationUpload `json:"models"`
@ -832,7 +885,7 @@ type setStarGiftEnabledAPIRequest struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
Reason string `json:"reason"` Reason string `json:"reason"`
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"` GiftID int64 `json:"gift_id,string"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
@ -853,7 +906,7 @@ type setStarGiftSortOrderAPIRequest struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
Reason string `json:"reason"` Reason string `json:"reason"`
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"` GiftID int64 `json:"gift_id,string"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
} }

View file

@ -82,3 +82,76 @@ func TestSetAccountFrozenBFFForwardsClientVisibleState(t *testing.T) {
t.Fatalf("forwarded freeze request = %+v", got) 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)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,13 +1,13 @@
<!doctype html> <!doctype html>
<html lang="en" translate="no"> <html lang="en" translate="no">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title> <title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-BFkUM6v2.js"></script> <script type="module" crossorigin src="/assets/index-DKmJO2ZY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BaxMq_AT.css"> <link rel="stylesheet" crossorigin href="/assets/index-DHdrFM5j.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>

View file

@ -8,6 +8,7 @@ import type {
GroupMessageListResponse, GroupMessageListResponse,
MessageDetail, MessageDetail,
MessageListResponse, MessageListResponse,
OfficialStarGiftListResponse,
StarGiftCollectiblePreview, StarGiftCollectiblePreview,
StarGiftListResponse StarGiftListResponse
} from "./types"; } from "./types";
@ -66,11 +67,14 @@ export const api = {
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`); return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
}, },
gifts: () => request<StarGiftListResponse>("/api/gifts"), gifts: () => request<StarGiftListResponse>("/api/gifts"),
giftAnimation: (id: number) => request<Record<string, unknown>>(`/api/gifts/${id}/animation`), officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
giftCollectibles: (id: number) => request<StarGiftCollectiblePreview>(`/api/gifts/${id}/collectibles`), officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
giftCollectibleAnimation: (giftID: number, kind: "model" | "pattern", attributeID: number) => request<Record<string, unknown>>(`/api/gifts/${giftID}/collectibles/${kind}/${attributeID}/animation`), giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),
giftCollectibles: (id: string) => request<StarGiftCollectiblePreview>(`/api/gifts/${encodeURIComponent(id)}/collectibles`),
giftCollectibleAnimation: (giftID: string, kind: "model" | "pattern", attributeID: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(giftID)}/collectibles/${kind}/${encodeURIComponent(attributeID)}/animation`),
importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }), importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }),
publishGiftCollectibles: (giftID: number, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${giftID}`, { method: "POST", body: form }), importOfficialGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-official-gift", { method: "POST", body: JSON.stringify(payload) }),
publishGiftCollectibles: (giftID: string, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(giftID)}`, { method: "POST", body: form }),
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, { action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)

View file

@ -260,6 +260,26 @@ const translations: Record<Language, Record<string, string>> = {
"gifts.importEyebrow": "Gift catalog operation", "gifts.importEyebrow": "Gift catalog operation",
"gifts.newRevision": "Create revision for gift #{id}", "gifts.newRevision": "Create revision for gift #{id}",
"gifts.importHint": "Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.", "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.animation": "Animation file",
"gifts.filePrompt": "Drop or choose a TGS / Lottie file", "gifts.filePrompt": "Drop or choose a TGS / Lottie file",
"gifts.fileHint": "TGS, JSON or Lottie · validated before import", "gifts.fileHint": "TGS, JSON or Lottie · validated before import",
@ -307,7 +327,7 @@ const translations: Record<Language, Record<string, string>> = {
"collectibles.pattern": "Pattern", "collectibles.pattern": "Pattern",
"collectibles.backdrop": "Backdrop", "collectibles.backdrop": "Backdrop",
"collectibles.rarity": "Rarity ‰", "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.colorHint": "Colors are stored as Telegram 24-bit RGB values.",
"collectibles.addAttribute": "Add", "collectibles.addAttribute": "Add",
"collectibles.remove": "Remove attribute", "collectibles.remove": "Remove attribute",
@ -610,6 +630,26 @@ const translations: Record<Language, Record<string, string>> = {
"gifts.importEyebrow": "礼物目录操作", "gifts.importEyebrow": "礼物目录操作",
"gifts.newRevision": "为礼物 #{id} 创建新版本", "gifts.newRevision": "为礼物 #{id} 创建新版本",
"gifts.importHint": "支持 TGS 或纯 Lottie JSONLottie 会规范化并压缩成 TGS。", "gifts.importHint": "支持 TGS 或纯 Lottie JSONLottie 会规范化并压缩成 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.animation": "动画文件",
"gifts.filePrompt": "拖放或选择 TGS / Lottie 文件", "gifts.filePrompt": "拖放或选择 TGS / Lottie 文件",
"gifts.fileHint": "支持 TGS、JSON、Lottie导入前会先进行校验", "gifts.fileHint": "支持 TGS、JSON、Lottie导入前会先进行校验",
@ -657,7 +697,7 @@ const translations: Record<Language, Record<string, string>> = {
"collectibles.pattern": "图案", "collectibles.pattern": "图案",
"collectibles.backdrop": "背景", "collectibles.backdrop": "背景",
"collectibles.rarity": "稀有度 ‰", "collectibles.rarity": "稀有度 ‰",
"collectibles.rarityHint": "每一类的稀有度总和必须正好为 1000‰。", "collectibles.rarityHint": "Permille 是普通升级的相对权重,不要求每类合计正好为 1000。",
"collectibles.colorHint": "颜色会按 Telegram 24 位 RGB 数值保存。", "collectibles.colorHint": "颜色会按 Telegram 24 位 RGB 数值保存。",
"collectibles.addAttribute": "添加", "collectibles.addAttribute": "添加",
"collectibles.remove": "删除属性", "collectibles.remove": "删除属性",
@ -960,6 +1000,26 @@ const translations: Record<Language, Record<string, string>> = {
"gifts.importEyebrow": "Управление каталогом подарков", "gifts.importEyebrow": "Управление каталогом подарков",
"gifts.newRevision": "Создать версию для подарка #{id}", "gifts.newRevision": "Создать версию для подарка #{id}",
"gifts.importHint": "Загрузите файл TGS или обычный Lottie JSON. Lottie нормализуется и сжимается в формат TGS.", "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.animation": "Файл анимации",
"gifts.filePrompt": "Перетащите или выберите файл TGS / Lottie", "gifts.filePrompt": "Перетащите или выберите файл TGS / Lottie",
"gifts.fileHint": "TGS, JSON или Lottie · файл проверяется перед импортом", "gifts.fileHint": "TGS, JSON или Lottie · файл проверяется перед импортом",
@ -1007,7 +1067,7 @@ const translations: Record<Language, Record<string, string>> = {
"collectibles.pattern": "Узор", "collectibles.pattern": "Узор",
"collectibles.backdrop": "Фон", "collectibles.backdrop": "Фон",
"collectibles.rarity": "Редкость ‰", "collectibles.rarity": "Редкость ‰",
"collectibles.rarityHint": "Сумма по каждому разделу должна составлять ровно 1000‰.", "collectibles.rarityHint": "Значения permille — это относительные веса обычного улучшения; их сумма не обязана равняться 1000.",
"collectibles.colorHint": "Цвета сохраняются как 24-битные RGB-значения Telegram.", "collectibles.colorHint": "Цвета сохраняются как 24-битные RGB-значения Telegram.",
"collectibles.addAttribute": "Добавить", "collectibles.addAttribute": "Добавить",
"collectibles.remove": "Удалить атрибут", "collectibles.remove": "Удалить атрибут",

View file

@ -44,7 +44,7 @@ function AnimationPreview({ data, compact = false }: { data: AnimationData; comp
return <div className={`collectible-animation ${compact ? "compact" : ""}`} ref={host} />; return <div className={`collectible-animation ${compact ? "compact" : ""}`} ref={host} />;
} }
function RemoteAnimation({ giftID, attribute }: { giftID: number; attribute: StarGiftCollectibleAttributeRow }) { function RemoteAnimation({ giftID, attribute }: { giftID: string; attribute: StarGiftCollectibleAttributeRow }) {
const [data, setData] = useState<AnimationData | null>(null); const [data, setData] = useState<AnimationData | null>(null);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
useEffect(() => { useEffect(() => {
@ -74,6 +74,7 @@ async function parseAnimationFile(file: File): Promise<AnimationData> {
} }
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16); 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 }) { export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
const { t } = useI18n(); 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 })); 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({ form.set("metadata", JSON.stringify({
command_id: commandID, reason: reason.trim(), confirm, 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), models: animatedMetadata(models), patterns: animatedMetadata(patterns),
backdrops: backdrops.map((row) => ({ backdrops: backdrops.map((row) => ({
name: row.name.trim(), backdrop_id: Number(row.backdropID), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), 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
<section className="collectible-section"> <section className="collectible-section">
<div className="collectible-section-head"> <div className="collectible-section-head">
<div><strong>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div> <div><strong>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div>
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] === 1000 ? "good" : "neutral"}>{rarityTotals[kind]} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div> <div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
</div> </div>
<div className="collectible-rows"> <div className="collectible-rows">
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}> {rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
@ -194,8 +195,8 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active"> {loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active">
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div> <div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div>
<div className="collectible-active-grid"> <div className="collectible-active-grid">
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}</strong><span>{t(`collectibles.${attribute.kind}`)} · {attribute.rarity_permille}</span></div></article>)} {[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{t(`collectibles.${attribute.kind}`)} · {rarityLabel(attribute)}</span></div></article>)}
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {attribute.rarity_permille}</span></div></article>)} {(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {rarityLabel(attribute)}</span></div></article>)}
</div> </div>
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>} </section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>}
@ -210,11 +211,11 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
{renderAnimatedRows("models", models, setModels)} {renderAnimatedRows("models", models, setModels)}
{renderAnimatedRows("patterns", patterns, setPatterns)} {renderAnimatedRows("patterns", patterns, setPatterns)}
<section className="collectible-section"> <section className="collectible-section">
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops === 1000 ? "good" : "neutral"}>{rarityTotals.backdrops} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div> <div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
<div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}> <div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
<div className="collectible-row-index">{index + 1}</div> <div className="collectible-row-index">{index + 1}</div>
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label> <label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="1" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label> <label><span>{t("collectibles.backdropID")}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label> <label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label> <label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)} {(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}

View file

@ -7,16 +7,23 @@ import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { formatDate } from "../lib/format"; import { formatDate } from "../lib/format";
import type { CommandResult, StarGiftRow } from "../types"; import type { CommandResult, OfficialStarGiftRow, StarGiftRow } from "../types";
import { GiftCollectiblesModal } from "./GiftCollectiblesModal"; import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
function formatBytes(bytes: number) { type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; function officialGiftAttributeCount(gift: OfficialStarGiftRow) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 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<HTMLDivElement>(null); const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null); const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [playing, setPlaying] = useState(true); 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<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
let player: ReturnType<typeof lottie.loadAnimation> | 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 <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
}
export function GiftsPage() { export function GiftsPage() {
const { t } = useI18n(); const { t } = useI18n();
const [gifts, setGifts] = useState<StarGiftRow[]>([]); const [gifts, setGifts] = useState<StarGiftRow[]>([]);
@ -66,7 +87,16 @@ export function GiftsPage() {
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null); const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [giftID, setGiftID] = useState(0); const [importSource, setImportSource] = useState<"official" | "file">("official");
const [officialGifts, setOfficialGifts] = useState<OfficialStarGiftRow[]>([]);
const [officialQuery, setOfficialQuery] = useState("");
const [officialCategory, setOfficialCategory] = useState<OfficialGiftCategory>("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 [title, setTitle] = useState("");
const [stars, setStars] = useState("50"); const [stars, setStars] = useState("50");
const [convertStars, setConvertStars] = useState("50"); const [convertStars, setConvertStars] = useState("50");
@ -89,6 +119,29 @@ export function GiftsPage() {
useEffect(() => { void load(); }, []); 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 visibleGifts = useMemo(() => {
const normalized = query.trim().toLowerCase(); const normalized = query.trim().toLowerCase();
if (!normalized) return gifts; if (!normalized) return gifts;
@ -107,10 +160,10 @@ export function GiftsPage() {
command_id: commandID, command_id: commandID,
reason: reason.trim(), reason: reason.trim(),
confirm, confirm,
gift_id: giftID, gift_id: giftID,
title: title.trim(), title: title.trim(),
stars: Number(stars), stars,
convert_stars: Number(convertStars), convert_stars: convertStars,
enabled, enabled,
sort_order: Number(sortOrder) sort_order: Number(sortOrder)
})); }));
@ -118,10 +171,34 @@ export function GiftsPage() {
return form; 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() { async function validateImport() {
setBusy(true); setImportError(""); setPreview(null); setBusy(true); setImportError(""); setPreview(null);
try { try {
setPreview(await api.importGift(uploadForm(false))); setPreview(importSource === "official" ? await api.importOfficialGift(officialPayload(false)) : await api.importGift(uploadForm(false)));
} catch (err) { } catch (err) {
setImportError(errorMessage(err)); setImportError(errorMessage(err));
} finally { setBusy(false); } } finally { setBusy(false); }
@ -131,8 +208,9 @@ export function GiftsPage() {
if (!preview) return; if (!preview) return;
setBusy(true); setImportError(""); setBusy(true); setImportError("");
try { try {
await api.importGift(uploadForm(true, preview.command_id)); if (importSource === "official") await api.importOfficialGift(officialPayload(true, preview.command_id));
setPreview(null); setFile(null); setGiftID(0); setTitle(""); else await api.importGift(uploadForm(true, preview.command_id));
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSourceGiftID("");
await load(); await load();
setImportOpen(false); setImportOpen(false);
} catch (err) { } catch (err) {
@ -141,14 +219,16 @@ export function GiftsPage() {
} }
function startImport() { function startImport() {
setGiftID(0); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0"); setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true); setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError("");
setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
} }
function startRevision(gift: StarGiftRow) { function startRevision(gift: StarGiftRow) {
setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars)); setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars));
setConvertStars(String(gift.ConvertStars)); setSortOrder(String(gift.SortOrder)); setEnabled(gift.Enabled); 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 ( return (
@ -160,7 +240,7 @@ export function GiftsPage() {
<div className="metric-row gift-metrics"> <div className="metric-row gift-metrics">
<Metric label={t("gifts.total")} value={String(gifts.length)} /> <Metric label={t("gifts.total")} value={String(gifts.length)} />
<Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" /> <Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
<Metric label={t("gifts.received")} value={String(gifts.reduce((sum, gift) => sum + gift.ReceivedCount, 0))} /> <Metric label={t("gifts.received")} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
<Metric label={t("gifts.formats")} value="TGS / Lottie" /> <Metric label={t("gifts.formats")} value="TGS / Lottie" />
</div> </div>
<QueryPanel> <QueryPanel>
@ -193,24 +273,77 @@ export function GiftsPage() {
{importOpen && createPortal( {importOpen && createPortal(
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}> <section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
<div className="modal-head"> <div className="modal-head">
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div> <div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button> <button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
</div> </div>
<div className="command-body gift-import-modal-body"> <div className="command-body gift-import-modal-body">
<div className="command-steps"> <div className="command-steps">
<div className={`command-step ${file ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div> <div className={`command-step ${(importSource === "official" ? sourceGiftID : file) ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
<div className={`command-step ${preview ? "done" : file ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div> <div className={`command-step ${preview ? "done" : (importSource === "official" ? sourceGiftID : file) ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div> <div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
</div> </div>
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div> <div className="gift-source-tabs">
<label className={`gift-file-picker ${file ? "has-file" : ""}`}> <button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button>
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} /> <button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button>
<span className="gift-file-icon"><FileJson2 size={22} /></span> </div>
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span> {importSource === "official" ? <section className="official-gift-picker">
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span> <div className="gift-import-note"><span>{t("gifts.officialHint")}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
</label> <div className="official-gift-tools">
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={t("gifts.officialSearch")} /></label>
<span>{t("gifts.officialResults", { shown: visibleOfficial.length, total: officialGifts.length })}</span>
</div>
<div className="official-gift-categories" role="group" aria-label={t("gifts.officialCategoryLabel")}>
{(["all", "upgrade", "craft", "basic"] as const).map((category) => (
<button key={category} className={officialCategory === category ? "active" : ""} type="button"
aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}>
{t(`gifts.officialCategory.${category}`)}<span>{officialCategoryCounts[category]}</span>
</button>
))}
</div>
<div className="official-gift-list" role="listbox" aria-label={t("gifts.officialSelect")}>
{visibleOfficial.map((gift) => {
const selected = gift.source_gift_id === sourceGiftID;
return <button key={gift.source_gift_id} className={`official-gift-option ${selected ? "selected" : ""}`}
type="button" role="option" aria-selected={selected} onClick={() => chooseOfficial(gift)}>
<span className="official-gift-option-head">
<strong>{gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id })}</strong>
<span className="mono">#{gift.source_gift_id}</span>
</span>
<span className="official-gift-option-meta">
<span> {gift.stars}</span>
<span>{t("gifts.officialAttributes", { count: officialGiftAttributeCount(gift) })}</span>
</span>
<span className="official-gift-capabilities">
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span>
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span>
</span>
</button>;
})}
{visibleOfficial.length === 0 && <div className="official-gift-empty">{t("gifts.officialEmpty")}</div>}
</div>
{selectedOfficial && <div className="official-gift-selected">
<OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} />
<div><strong>{selectedOfficial.title || t("gifts.officialUnnamed", { id: selectedOfficial.source_gift_id })}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {t("collectibles.models")} · {selectedOfficial.pattern_count} {t("collectibles.patterns")} · {selectedOfficial.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
</div>}
{selectedOfficial?.can_upgrade && <>
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.includeCollectible")}</span></label>
{includeCollectible && <div className="gift-fields-grid">
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
</div>}
</>}
</section> : <>
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div>
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
<span className="gift-file-icon"><FileJson2 size={22} /></span>
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span>
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
</label>
</>}
<div className="gift-fields-grid"> <div className="gift-fields-grid">
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label> <label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label> <label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>

View file

@ -276,6 +276,60 @@
.gift-import-modal { width: min(860px, 100%); } .gift-import-modal { width: min(860px, 100%); }
.gift-import-modal-body { gap: 14px; } .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-import-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); line-height: 1.45; }
.gift-file-picker { .gift-file-picker {
@ -321,6 +375,7 @@
.gift-fields-grid input, .gift-fields-grid input,
.gift-reason-field input { .gift-reason-field input {
width: 100%;
min-width: 0; min-width: 0;
height: 38px; height: 38px;
padding: 0 10px; padding: 0 10px;
@ -440,6 +495,10 @@
.gift-file-action { display: none; } .gift-file-action { display: none; }
.gift-fields-grid { grid-template-columns: 1fr; } .gift-fields-grid { grid-template-columns: 1fr; }
.gift-list-summary { width: 100%; margin-left: 0; } .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-modal-body { padding: 10px; }
.collectible-definition-head, .collectible-definition-head,
.collectible-section-head { align-items: flex-start; flex-direction: column; } .collectible-section-head { align-items: flex-start; flex-direction: column; }

View file

@ -161,34 +161,58 @@ export type OutboxRow = {
}; };
export type StarGiftRow = { export type StarGiftRow = {
GiftID: number; GiftID: string;
RevisionID: number; RevisionID: string;
Revision: number; Revision: number;
Title: string; Title: string;
Stars: number; Stars: string;
ConvertStars: number; ConvertStars: string;
Enabled: boolean; Enabled: boolean;
SortOrder: number; SortOrder: number;
DocumentID: number; DocumentID: string;
SourceName: string; SourceName: string;
SourceFormat: "tgs" | "lottie"; SourceFormat: "tgs" | "lottie";
AnimationSHA: string; AnimationSHA: string;
AnimationSize: number; AnimationSize: string;
Width: number; Width: number;
Height: number; Height: number;
FrameRate: number; FrameRate: number;
ReceivedCount: number; ReceivedCount: string;
CreatedBy: string; CreatedBy: string;
UpdatedAt: string; UpdatedAt: string;
}; };
export type StarGiftListResponse = { Gifts: StarGiftRow[] }; 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 = { export type StarGiftCollectibleAttributeRow = {
id: number; id: string;
kind: "model" | "pattern" | "backdrop"; kind: "model" | "pattern" | "backdrop";
name: string; name: string;
rarity_kind: "permille" | "uncommon" | "rare" | "epic" | "legendary";
rarity_permille: number; rarity_permille: number;
crafted: boolean;
official_document_id: string;
sort_order: number; sort_order: number;
source_name?: string; source_name?: string;
source_format?: "tgs" | "lottie"; source_format?: "tgs" | "lottie";
@ -201,9 +225,9 @@ export type StarGiftCollectibleAttributeRow = {
export type StarGiftCollectiblePreview = { export type StarGiftCollectiblePreview = {
found: boolean; found: boolean;
gift_id: number; gift_id: string;
revision?: number; revision?: number;
upgrade_stars?: number; upgrade_stars?: string;
supply_total?: number; supply_total?: number;
issued?: number; issued?: number;
slug_prefix?: string; slug_prefix?: string;

View file

@ -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 package main
import ( import (
@ -56,6 +56,7 @@ import (
"telesrv/internal/config" "telesrv/internal/config"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
"telesrv/internal/officialgifts"
"telesrv/internal/otpdelivery" "telesrv/internal/otpdelivery"
otpsmtp "telesrv/internal/otpdelivery/smtp" otpsmtp "telesrv/internal/otpdelivery/smtp"
otpwebhook "telesrv/internal/otpdelivery/webhook" otpwebhook "telesrv/internal/otpdelivery/webhook"
@ -467,8 +468,9 @@ func run(logger *zap.Logger) error {
rateLimiter := redisstore.NewRateLimiter(rdb) rateLimiter := redisstore.NewRateLimiter(rdb)
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions")) activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
adminService := adminapp.NewService(adminapp.Dependencies{ adminService := adminapp.NewService(adminapp.Dependencies{
Commands: adminStore, Commands: adminStore,
Restrictions: adminStore, Restrictions: adminStore,
OfficialGifts: officialgifts.New(cfg.OfficialGiftsDir),
}) })
go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"), go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
cfg.UpdateEventRetention, cfg.UpdateEventRetention,
@ -663,9 +665,26 @@ func run(logger *zap.Logger) error {
starsStore := postgres.NewStarsStore(pool) starsStore := postgres.NewStarsStore(pool)
starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant)) starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant))
starGiftStore := postgres.NewStarGiftStore(pool) 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, giftsService := stargifts.NewService(starGiftStore, blobBackend, cfg.DC,
stargifts.WithUpgradeStore(starGiftUpgradeStore)) stargifts.WithUpgradeStore(starGiftUpgradeStore),
stargifts.WithLifecycleStore(starGiftLifecycleStore),
stargifts.WithWithdrawalProvider(starGiftWithdrawalProvider))
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token // Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
// 同属进程内一次性凭据,不跨实例)。 // 同属进程内一次性凭据,不跨实例)。
passkeyStore := postgres.NewPasskeyStore(pool) passkeyStore := postgres.NewPasskeyStore(pool)
@ -852,6 +871,32 @@ func run(logger *zap.Logger) error {
go router.RunPresenceSweeper(ctx, time.Minute) go router.RunPresenceSweeper(ctx, time.Minute)
go activeSessions.RunPendingSweeper(ctx, time.Minute) go activeSessions.RunPendingSweeper(ctx, time.Minute)
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch) 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) go router.RunInlineBotPushSubscriber(ctx)
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil { if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil {
return fmt.Errorf("start bot api: %w", err) 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) return fmt.Errorf("start admin api: %w", err)
} }
if _, err := web.Start(ctx, web.Config{ if _, err := web.Start(ctx, web.Config{
Addr: cfg.PublicLinkWebAddr, Addr: cfg.PublicLinkWebAddr,
PublicBaseURL: cfg.PublicBaseURL, PublicBaseURL: cfg.PublicBaseURL,
AppScheme: cfg.PublicAppScheme, AppScheme: cfg.PublicAppScheme,
WebBaseURL: cfg.PublicWebBaseURL, WebBaseURL: cfg.PublicWebBaseURL,
AppName: cfg.PublicAppName, AppName: cfg.PublicAppName,
StickerSets: filesService, StickerSets: filesService,
Users: userStore, Users: userStore,
Channels: channelStore, Channels: channelStore,
Privacy: privacyService, Privacy: privacyService,
Photos: filesService, Photos: filesService,
UniqueGifts: giftsService,
GiftWithdrawals: giftsService,
}, logger.Named("public-web")); err != nil { }, logger.Named("public-web")); err != nil {
return fmt.Errorf("start public Web: %w", err) return fmt.Errorf("start public Web: %w", err)
} }

View file

@ -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;
$$;

View file

@ -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;
$$;

View file

@ -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;

View file

@ -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)
);

View file

@ -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);

View file

@ -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();

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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)
);

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS public.channel_ton_transactions;
DROP TABLE IF EXISTS public.channel_ton_balances;

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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.

View file

@ -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
$$;

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.star_gift_purchase_forms;

View file

@ -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);

View file

@ -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;

View file

@ -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
$$;

View file

@ -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;

View file

@ -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
$$;

View file

@ -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;

View file

@ -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
);

View file

@ -4,15 +4,18 @@ import (
"context" "context"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"math" "math"
"net/url" "net/url"
"reflect" "reflect"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/officialgifts"
) )
const ( const (
@ -25,6 +28,7 @@ const (
ActionDeletePrivateMessages = "messages.delete_private_messages" ActionDeletePrivateMessages = "messages.delete_private_messages"
ActionDeletePrivateHistory = "messages.delete_private_history" ActionDeletePrivateHistory = "messages.delete_private_history"
ActionImportStarGift = "gifts.import" ActionImportStarGift = "gifts.import"
ActionImportOfficialStarGift = "gifts.official.import"
ActionPublishGiftCollectibles = "gifts.collectibles.publish" ActionPublishGiftCollectibles = "gifts.collectibles.publish"
ActionSetStarGiftEnabled = "gifts.set_enabled" ActionSetStarGiftEnabled = "gifts.set_enabled"
ActionSetStarGiftSortOrder = "gifts.set_sort_order" ActionSetStarGiftSortOrder = "gifts.set_sort_order"
@ -94,7 +98,9 @@ type MessagesService interface {
type GiftsService interface { type GiftsService interface {
PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) 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) 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) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error)
SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error)
AnimationJSON(ctx context.Context, giftID int64) ([]byte, 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) 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 { type Dependencies struct {
Commands CommandRepository Commands CommandRepository
Restrictions RestrictionStore Restrictions RestrictionStore
@ -116,6 +127,7 @@ type Dependencies struct {
ChannelNotifier ChannelNotifier ChannelNotifier ChannelNotifier
Messages MessagesService Messages MessagesService
Gifts GiftsService Gifts GiftsService
OfficialGifts OfficialGiftsSource
Now func() time.Time Now func() time.Time
} }
@ -132,6 +144,7 @@ type Service struct {
channelNotifier ChannelNotifier channelNotifier ChannelNotifier
messages MessagesService messages MessagesService
gifts GiftsService gifts GiftsService
officialGifts OfficialGiftsSource
now func() time.Time now func() time.Time
} }
@ -177,6 +190,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Gifts != nil { if deps.Gifts != nil {
s.gifts = deps.Gifts s.gifts = deps.Gifts
} }
if deps.OfficialGifts != nil {
s.officialGifts = deps.OfficialGifts
}
if deps.Now != nil { if deps.Now != nil {
s.now = deps.Now s.now = deps.Now
} }
@ -219,6 +235,23 @@ type ImportStarGiftRequest struct {
Data []byte `json:"-"` 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 { type SetStarGiftEnabledRequest struct {
CommandMeta CommandMeta
GiftID int64 `json:"gift_id"` GiftID int64 `json:"gift_id"`
@ -796,8 +829,9 @@ func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest)
req.ContentSHA = hex.EncodeToString(animation.SHA256) req.ContentSHA = hex.EncodeToString(animation.SHA256)
return s.runCommand(ctx, req.CommandMeta, ActionImportStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) { return s.runCommand(ctx, req.CommandMeta, ActionImportStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{ details := map[string]any{
"gift_id": req.GiftID, "title": strings.TrimSpace(req.Title), "stars": req.Stars, "gift_id": strconv.FormatInt(req.GiftID, 10), "title": strings.TrimSpace(req.Title),
"convert_stars": req.ConvertStars, "enabled": req.Enabled, "sort_order": req.SortOrder, "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, "source_format": animation.SourceFormat, "source_name": animation.SourceName,
"sha256": req.ContentSHA, "width": animation.Width, "height": animation.Height, "sha256": req.ContentSHA, "width": animation.Width, "height": animation.Height,
"frame_rate": animation.FrameRate, "compressed_bytes": len(animation.TGS), "json_bytes": len(animation.JSON), "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 { if err != nil {
return CommandResult{Details: details}, err return CommandResult{Details: details}, err
} }
details["gift_id"] = entry.Gift.ID details["gift_id"] = strconv.FormatInt(entry.Gift.ID, 10)
details["revision_id"] = entry.Gift.RevisionID details["revision_id"] = strconv.FormatInt(entry.Gift.RevisionID, 10)
details["revision"] = entry.Revision details["revision"] = entry.Revision
return CommandResult{Message: "star gift imported", Details: details}, nil 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) { func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishStarGiftCollectiblesRequest) (CommandResult, error) {
if s == nil || s.gifts == nil { if s == nil || s.gifts == nil {
return CommandResult{}, fmt.Errorf("star gift service is not configured") 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) uploads[i].ContentSHA = hex.EncodeToString(animation.SHA256)
attributes[i] = domain.StarGiftCollectibleAttribute{ attributes[i] = domain.StarGiftCollectibleAttribute{
Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityPermille: uploads[i].RarityPermille, Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityKind: domain.StarGiftRarityPermille,
SortOrder: uploads[i].SortOrder, Animation: &animation, RarityPermille: uploads[i].RarityPermille,
SortOrder: uploads[i].SortOrder, Animation: &animation,
} }
} }
return attributes, nil return attributes, nil
@ -852,7 +1106,8 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt
backdrops[i] = domain.StarGiftCollectibleAttribute{ backdrops[i] = domain.StarGiftCollectibleAttribute{
Kind: domain.StarGiftCollectibleBackdrop, Name: strings.TrimSpace(backdrop.Name), BackdropID: backdrop.BackdropID, Kind: domain.StarGiftCollectibleBackdrop, Name: strings.TrimSpace(backdrop.Name), BackdropID: backdrop.BackdropID,
CenterColor: backdrop.CenterColor, EdgeColor: backdrop.EdgeColor, PatternColor: backdrop.PatternColor, 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{ 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) { return s.runCommand(ctx, req.CommandMeta, ActionPublishGiftCollectibles, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{ details := map[string]any{
"gift_id": req.GiftID, "upgrade_stars": req.UpgradeStars, "supply_total": req.SupplyTotal, "gift_id": strconv.FormatInt(req.GiftID, 10), "upgrade_stars": strconv.FormatInt(req.UpgradeStars, 10),
"slug_prefix": write.SlugPrefix, "models": collectibleUploadDetails(req.Models), "supply_total": req.SupplyTotal,
"slug_prefix": write.SlugPrefix, "models": collectibleUploadDetails(req.Models),
"patterns": collectibleUploadDetails(req.Patterns), "backdrops": len(req.Backdrops), "patterns": collectibleUploadDetails(req.Patterns), "backdrops": len(req.Backdrops),
} }
if req.DryRun { if req.DryRun {
@ -884,7 +1140,7 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt
if err != nil { if err != nil {
return CommandResult{Details: details}, err return CommandResult{Details: details}, err
} }
details["revision_id"] = revision.ID details["revision_id"] = strconv.FormatInt(revision.ID, 10)
details["revision"] = revision.Revision details["revision"] = revision.Revision
details["published"] = revision.Published details["published"] = revision.Published
return CommandResult{Message: "star gift collectible pool published", Details: details}, nil 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 CommandResult{}, fmt.Errorf("valid star gift and service are required")
} }
return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftEnabled, 0, domain.Peer{}, req, func() (CommandResult, error) { 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 { if req.DryRun {
return CommandResult{Message: "star gift state change validated", Details: details}, nil 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 CommandResult{}, fmt.Errorf("valid star gift and service are required")
} }
return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) { 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 { if req.DryRun {
return CommandResult{Message: "star gift order change validated", Details: details}, nil return CommandResult{Message: "star gift order change validated", Details: details}, nil
} }

View file

@ -1,6 +1,7 @@
package admin package admin
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"errors" "errors"
@ -10,6 +11,7 @@ import (
"time" "time"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/officialgifts"
) )
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) { 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} base.CommandMeta = CommandMeta{CommandID: "exec-gift", Actor: "ops", Reason: "catalog", DryRun: false}
result, err := svc.ImportStarGift(context.Background(), base) 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) 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} base.CommandMeta = CommandMeta{CommandID: "exec-collectibles", Actor: "ops", Reason: "pool", DryRun: false}
result, err := svc.PublishStarGiftCollectibles(context.Background(), base) 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) 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) { func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
sum := sha256.Sum256(data) 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, JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: sum[:], Width: 512, Height: 512, FrameRate: 30,
}, nil }, 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) { func (f *fakeGiftsService) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
f.createCalls++ f.createCalls++
return domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Stars}, Revision: 1}, nil 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) { func (*fakeGiftsService) SetCatalogEnabled(context.Context, int64, bool) (bool, error) {
return true, nil return true, nil
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -15,6 +16,7 @@ import (
"telesrv/internal/admin" "telesrv/internal/admin"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/officialgifts"
) )
type Config struct { type Config struct {
@ -32,6 +34,9 @@ type Service interface {
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error) DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error) DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error)
ImportStarGift(ctx context.Context, req admin.ImportStarGiftRequest) (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) PublishStarGiftCollectibles(ctx context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error)
SetStarGiftEnabled(ctx context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error) SetStarGiftEnabled(ctx context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error)
SetStarGiftSortOrder(ctx context.Context, req admin.SetStarGiftSortOrderRequest) (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", s.authenticated(s.handleDeleteMessages))
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory)) mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
mux.HandleFunc("POST /v1/gifts/import", s.authenticated(s.handleImportStarGift)) 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/{id}/collectibles/publish", s.authenticated(s.handlePublishStarGiftCollectibles))
mux.HandleFunc("POST /v1/gifts/set-enabled", s.authenticated(s.handleSetStarGiftEnabled)) mux.HandleFunc("POST /v1/gifts/set-enabled", s.authenticated(s.handleSetStarGiftEnabled))
mux.HandleFunc("POST /v1/gifts/set-sort-order", s.authenticated(s.handleSetStarGiftSortOrder)) 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) 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) { func (s *Server) handlePublishStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() defer r.Body.Close()
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
@ -338,7 +400,7 @@ func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Reque
return return
} }
if !found { 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 return
} }
writeJSON(w, http.StatusOK, collectiblePreviewResponse(preview)) 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 { func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[string]any {
attribute := func(value domain.StarGiftCollectibleAttribute) map[string]any { attribute := func(value domain.StarGiftCollectibleAttribute) map[string]any {
result := map[string]any{ result := map[string]any{
"id": value.ID, "name": value.Name, "rarity_permille": value.RarityPermille, "id": strconv.FormatInt(value.ID, 10), "name": value.Name, "rarity_kind": value.RarityKind,
"sort_order": value.SortOrder, "kind": value.Kind, "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 { if value.Animation != nil {
result["source_name"] = value.Animation.SourceName result["source_name"] = value.Animation.SourceName
@ -371,8 +435,9 @@ func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[strin
return result return result
} }
return map[string]any{ return map[string]any{
"found": true, "gift_id": preview.GiftID, "revision": preview.Revision, "upgrade_stars": preview.UpgradeStars, "found": true, "gift_id": strconv.FormatInt(preview.GiftID, 10), "revision": preview.Revision,
"supply_total": preview.SupplyTotal, "issued": preview.Issued, "upgrade_stars": strconv.FormatInt(preview.UpgradeStars, 10),
"supply_total": preview.SupplyTotal, "issued": preview.Issued,
"slug_prefix": preview.SlugPrefix, "slug_prefix": preview.SlugPrefix,
"models": mapAttributes(preview.Models), "patterns": mapAttributes(preview.Patterns), "models": mapAttributes(preview.Models), "patterns": mapAttributes(preview.Patterns),
"backdrops": mapAttributes(preview.Backdrops), "backdrops": mapAttributes(preview.Backdrops),

View file

@ -11,6 +11,7 @@ import (
"telesrv/internal/admin" "telesrv/internal/admin"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/officialgifts"
) )
func TestAdminAPIRequiresBearerToken(t *testing.T) { 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 fakeService struct{}
type captureFreezeService 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 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) { func (fakeService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
} }

View file

@ -21,7 +21,18 @@ func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGif
return prepareAnimation(fileName, data) 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) { 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)) fileName = strings.TrimSpace(filepath.Base(fileName))
ext := strings.ToLower(filepath.Ext(fileName)) ext := strings.ToLower(filepath.Ext(fileName))
format := domain.StarGiftAnimationLottie format := domain.StarGiftAnimationLottie
@ -46,7 +57,7 @@ func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, e
rawJSON = data rawJSON = data
} }
normalized, meta, err := normalizeAndValidateLottie(rawJSON) normalized, meta, err := normalizeAndValidateLottie(rawJSON, allowExpressions)
if err != nil { if err != nil {
return domain.StarGiftAnimation{}, err return domain.StarGiftAnimation{}, err
} }
@ -80,7 +91,7 @@ type lottieMetadata struct {
Assets []json.RawMessage `json:"assets"` 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})) data = bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))
if len(data) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes || !json.Valid(data) { if len(data) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes || !json.Valid(data) {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
@ -94,7 +105,7 @@ func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) {
if _, ok := root.(map[string]any); !ok { if _, ok := root.(map[string]any); !ok {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
} }
if containsLottieExpression(root) { if !allowExpressions && containsLottieExpression(root) {
return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid) return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid)
} }
var meta lottieMetadata var meta lottieMetadata

View file

@ -91,3 +91,26 @@ func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
t.Fatalf("disable missing err=%v, want ErrStarGiftNotFound", err) 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)
}
}

View file

@ -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)

View file

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

View file

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

View file

@ -2,9 +2,12 @@
package stargifts package stargifts
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/base64"
"encoding/binary" "encoding/binary"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
@ -22,26 +25,65 @@ type BlobBackend interface {
} }
type Service struct { type Service struct {
store store.StarGiftStore store store.StarGiftStore
upgrades store.StarGiftUpgradeStore upgrades store.StarGiftUpgradeStore
blobs BlobBackend lifecycle store.StarGiftLifecycleStore
dc int withdrawal StarGiftWithdrawalProvider
blobs BlobBackend
dc int
mu sync.RWMutex mu sync.RWMutex
built bool built bool
gifts []domain.StarGift gifts []domain.StarGift
byID map[int64]domain.StarGift byID map[int64]domain.StarGift
hash int 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) type Option func(*Service)
func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option { func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option {
return func(service *Service) { service.upgrades = upgrades } 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 { 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 { for _, opt := range opts {
opt(service) opt(service)
} }
@ -137,21 +179,33 @@ func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGi
len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes { len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes {
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid 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) objectKey, err := s.blobs.Put(ctx, write.Animation.TGS)
if err != nil { 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() documentID, err := randomPositiveInt64()
if err != nil { if err != nil {
return domain.StarGiftCatalogEntry{}, err return err
} }
accessHash, err := randomPositiveInt64() accessHash, err := randomPositiveInt64()
if err != nil { if err != nil {
return domain.StarGiftCatalogEntry{}, err return err
} }
fileReference := make([]byte, 16) fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil { 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{ write.Document = domain.Document{
ID: documentID, ID: documentID,
@ -175,12 +229,66 @@ func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGi
SHA256: append([]byte(nil), write.Animation.SHA256...), SHA256: append([]byte(nil), write.Animation.SHA256...),
MimeType: "application/x-tgsticker", MimeType: "application/x-tgsticker",
} }
entry, err := s.store.CreateCatalogRevision(ctx, write) return nil
if err != nil { }
return domain.StarGiftCatalogEntry{}, err
// 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() write.Catalog.Title = strings.TrimSpace(write.Catalog.Title)
return entry, nil 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) { 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 { if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
return domain.StarGiftCollectibleRevision{}, err return domain.StarGiftCollectibleRevision{}, err
} }
materialize := func(attributes []domain.StarGiftCollectibleAttribute) error { if err := s.materializeCollectibleAttributes(ctx, write.Models); err != nil {
for i := range attributes {
animation := attributes[i].Animation
if animation == nil {
return domain.ErrStarGiftCollectibleInvalid
}
objectKey, err := s.blobs.Put(ctx, animation.TGS)
if err != nil {
return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return fmt.Errorf("generate collectible file reference: %w", err)
}
attributes[i].Document = &domain.Document{
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
Size: int64(len(animation.TGS)), DCID: s.dc,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"},
},
}
attributes[i].Blob = &domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey, Size: int64(len(animation.TGS)),
SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker",
}
}
return nil
}
if err := materialize(write.Models); err != nil {
return domain.StarGiftCollectibleRevision{}, err 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 domain.StarGiftCollectibleRevision{}, err
} }
return s.PublishCollectibleRevision(ctx, write) 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) { func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
if s == nil || s.store == nil || giftID <= 0 { if s == nil || s.store == nil || giftID <= 0 {
return domain.StarGiftUpgradePreview{}, false, nil return domain.StarGiftUpgradePreview{}, false, nil
@ -335,6 +444,338 @@ func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest
return result, err 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) { func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
return s.store.ListCollections(ctx, owner) 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) { 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) 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) 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) { func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
return s.store.MarkConverted(ctx, ref) 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) { func randomPositiveInt64() (int64, error) {
var raw [8]byte var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil { if _, err := rand.Read(raw[:]); err != nil {

View file

@ -171,6 +171,11 @@ type Config struct {
WebPagePreviewRatePerMin int WebPagePreviewRatePerMin int
// LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。 // LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。
LangPackSeedDir string LangPackSeedDir string
// OfficialGiftsDir 是 cmd/giftfetch 生成的只读官方礼物快照目录。
OfficialGiftsDir string
// StarGiftTONStartingGrant 是 telesrv 内部 TON 账本首次访问时授予的 nanoton。
// 该账本只用于自建服务端礼物链路,不连接任何外部区块链。
StarGiftTONStartingGrant int64
// BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。 // BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。
BlobDir string BlobDir string
// StickerSeedDir 是 reaction / sticker 资源种子目录(导入到 documents/sticker_sets + blob // StickerSeedDir 是 reaction / sticker 资源种子目录(导入到 documents/sticker_sets + blob
@ -311,6 +316,21 @@ type Config struct {
PremiumSweepInterval time.Duration PremiumSweepInterval time.Duration
// PremiumSweepBatch 是单次到期清理的最大行数。 // PremiumSweepBatch 是单次到期清理的最大行数。
PremiumSweepBatch int 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 态 // GroupCallCheckTTL 是群通话参与者保活水位的过期阈值(客户端 Connecting 态
// 4s 一跳M1 起 SFU liveness reporter 同样刷新该水位)。 // 4s 一跳M1 起 SFU liveness reporter 同样刷新该水位)。
@ -491,6 +511,8 @@ func Load() (Config, error) {
SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))), SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second), SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"), 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"), BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"), StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),
StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300), StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300),
@ -555,12 +577,24 @@ func Load() (Config, error) {
CallSignalingRate: envIntOr("TELESRV_CALL_SIGNALING_RATE", 50), CallSignalingRate: envIntOr("TELESRV_CALL_SIGNALING_RATE", 50),
CallExpiryInterval: envDurationOr("TELESRV_CALL_EXPIRY_INTERVAL", time.Second), CallExpiryInterval: envDurationOr("TELESRV_CALL_EXPIRY_INTERVAL", time.Second),
PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3), PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3),
PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"), PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"),
PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil), PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil),
StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)), StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)),
PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute), PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute),
PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500), 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), GroupCallCheckTTL: envDurationOr("TELESRV_GROUPCALL_CHECK_TTL", 45*time.Second),
GroupCallSweepInterval: envDurationOr("TELESRV_GROUPCALL_SWEEP_INTERVAL", 10*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 { if err := validateRPCResultCacheConfig(cfg); err != nil {
return Config{}, err return Config{}, err
} }
if err := validateStarGiftConfig(cfg); err != nil {
return Config{}, err
}
return cfg, nil 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)) const mtProtoRPCResultMinBytes = int64((1 << 24) - (2 << 10))
func validateRPCResultCacheConfig(cfg Config) error { func validateRPCResultCacheConfig(cfg Config) error {

View file

@ -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) { func writeConfigFile(t *testing.T, path, body string) {
t.Helper() t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil { if err := os.WriteFile(path, []byte(body), 0o600); err != nil {

View file

@ -550,6 +550,9 @@ const (
ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price" ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price"
// ChannelActionStarGift 映射 messageActionStarGift频道礼物的 admin-log 快照。 // ChannelActionStarGift 映射 messageActionStarGift频道礼物的 admin-log 快照。
ChannelActionStarGift ChannelMessageActionType = "star_gift" ChannelActionStarGift ChannelMessageActionType = "star_gift"
// ChannelActionStarGiftUnique 映射 messageActionStarGiftUniquecollectible
// 升级、转赠等所有权变更只进入 Recent Actions不伪造频道历史/pts。
ChannelActionStarGiftUnique ChannelMessageActionType = "star_gift_unique"
// ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper频道外观页设置 wallpaper。 // ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper频道外观页设置 wallpaper。
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper" ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
) )
@ -583,7 +586,8 @@ type ChannelMessageAction struct {
Incompleted []int Incompleted []int
TodoItems []MessageTodoItem TodoItems []MessageTodoItem
// StarGift 仅 star_gift 服务消息使用。 // StarGift 仅 star_gift 服务消息使用。
StarGift *MessageStarGiftAction StarGift *MessageStarGiftAction
StarGiftUnique *MessageStarGiftUniqueAction
// Wallpaper 仅 set_chat_wallpaper 服务消息使用。 // Wallpaper 仅 set_chat_wallpaper 服务消息使用。
Wallpaper *Wallpaper Wallpaper *Wallpaper
// Photo 仅 chat_edit_photo 服务消息使用。 // Photo 仅 chat_edit_photo 服务消息使用。

View file

@ -564,7 +564,9 @@ const (
// MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The // MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The
// immutable collectible snapshot is carried by the service message so an // immutable collectible snapshot is carried by the service message so an
// exact replay/difference never depends on mutable catalog state. // 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 的协议中立载荷。 // MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
@ -612,51 +614,87 @@ type MessageRequestedPeerAction struct {
// MessageServiceAction 是私聊服务消息动作的协议中立表示。 // MessageServiceAction 是私聊服务消息动作的协议中立表示。
type MessageServiceAction struct { type MessageServiceAction struct {
Kind MessageServiceActionKind `json:"kind"` Kind MessageServiceActionKind `json:"kind"`
Photo *Photo `json:"photo,omitempty"` Photo *Photo `json:"photo,omitempty"`
Call *MessagePhoneCallAction `json:"call,omitempty"` Call *MessagePhoneCallAction `json:"call,omitempty"`
ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"` ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"`
BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"` BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"`
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"` WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"` RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"` ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"` StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,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 的协议中立载荷:内嵌礼物快照(贴纸/星价) // MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方NameHidden 时下发不暴露 from。 // 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方NameHidden 时下发不暴露 from。
type MessageStarGiftAction struct { type MessageStarGiftAction struct {
GiftID int64 `json:"gift_id"` GiftID int64 `json:"gift_id"`
Stars int64 `json:"stars"` Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars,omitempty"` ConvertStars int64 `json:"convert_stars,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Sticker *Document `json:"sticker,omitempty"` Sticker *Document `json:"sticker,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
FromUserID int64 `json:"from_user_id,omitempty"` FromUserID int64 `json:"from_user_id,omitempty"`
PeerUserID int64 `json:"peer_user_id,omitempty"` PeerUserID int64 `json:"peer_user_id,omitempty"`
PeerChannelID int64 `json:"peer_channel_id,omitempty"` PeerChannelID int64 `json:"peer_channel_id,omitempty"`
SavedID int64 `json:"saved_id,omitempty"` SavedID int64 `json:"saved_id,omitempty"`
NameHidden bool `json:"name_hidden,omitempty"` NameHidden bool `json:"name_hidden,omitempty"`
Saved bool `json:"saved,omitempty"` Saved bool `json:"saved,omitempty"`
Converted bool `json:"converted,omitempty"` Converted bool `json:"converted,omitempty"`
CanUpgrade bool `json:"can_upgrade,omitempty"` CanUpgrade bool `json:"can_upgrade,omitempty"`
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"` PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
UpgradeStars int64 `json:"upgrade_stars,omitempty"` PrepaidUpgradeHash string `json:"prepaid_upgrade_hash,omitempty"`
UpgradeMsgID int `json:"upgrade_msg_id,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 { type MessageStarGiftUniqueAction struct {
Gift UniqueStarGift `json:"gift"` Gift UniqueStarGift `json:"gift"`
FromUserID int64 `json:"from_user_id,omitempty"` FromUserID int64 `json:"from_user_id,omitempty"`
Peer Peer `json:"peer"` Peer Peer `json:"peer"`
SavedID int64 `json:"saved_id,omitempty"` SavedID int64 `json:"saved_id,omitempty"`
Upgrade bool `json:"upgrade,omitempty"` Upgrade bool `json:"upgrade,omitempty"`
Saved bool `json:"saved,omitempty"` Saved bool `json:"saved,omitempty"`
PrepaidUpgrade bool `json:"prepaid_upgrade,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 快照)。 // MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。

View file

@ -24,29 +24,85 @@ type StarGift struct {
UpgradeIssued int // 当前已发行数量 UpgradeIssued int // 当前已发行数量
Title string // 可选标题 Title string // 可选标题
Sticker Document // 礼物贴纸快照tg 投影必须是带 sticker 属性的有效 Document否则客户端丢弃 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 一行)。 // SavedStarGift 是一条已收到的礼物实例peer_star_gifts 一行)。
type SavedStarGift struct { type SavedStarGift struct {
ID int64 ID int64
Owner Peer // 收礼 peeruser/channel Owner Peer // 收礼 peeruser/channel
FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露) FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露)
GiftID int64 // → StarGift.ID GiftID int64 // → StarGift.ID
RevisionID int64 // → star_gift_catalog_revisions.id历史查询必须按此版本投影 RevisionID int64 // → star_gift_catalog_revisions.id历史查询必须按此版本投影
MsgID int // 用户礼物的私聊 msg_id频道礼物不进历史固定为 0 MsgID int // 用户礼物的私聊 msg_id频道礼物不进历史固定为 0
SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id用户礼物为 0 SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id用户礼物为 0
Date int // 收到时刻 Unix 秒 Date int // 收到时刻 Unix 秒
NameHidden bool // 送礼人请求隐藏姓名 NameHidden bool // 送礼人请求隐藏姓名
Unsaved bool // 未展示在个人资料saveStarGift 切换) Unsaved bool // 未展示在个人资料saveStarGift 切换)
Converted bool // 已转换回 Stars终态从列表排除 Converted bool // 已转换回 Stars终态从列表排除
ConvertStars int64 // 转换可退回的 Stars LifecycleStatus StarGiftLifecycleStatus
PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额 ConvertStars int64 // 转换可退回的 Stars
Message string // 附言(可选) PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额
UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥 PrepaidUpgradeHash string // 第三方单独代付升级的一次性 entitlement
UpgradeMsgID int // messageActionStarGiftUnique 的 owner 侧消息 id GiftNum int // auction-acquired release number for regular gifts
PinnedOrder int // >0 表示资料页置顶顺序 Message string // 附言(可选)
CollectionIDs []int // 当前所属集合;按集合顺序稳定返回 UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥
Unique *UniqueStarGift 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 是唯一礼物三个必选属性槽位。 // StarGiftCollectibleAttributeKind 是唯一礼物三个必选属性槽位。
@ -58,8 +114,31 @@ const (
StarGiftCollectibleBackdrop StarGiftCollectibleAttributeKind = "backdrop" StarGiftCollectibleBackdrop StarGiftCollectibleAttributeKind = "backdrop"
) )
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityPermille 同时是客户端展示的 // StarGiftAttributeRarityKind mirrors the Layer 228 rarity union. Permille is the only
// 精确稀有度和升级抽取概率;同一 revision、同一 kind 的总和必须恰好为 1000。 // 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 { type StarGiftCollectibleAttribute struct {
ID int64 ID int64
CollectibleRevisionID int64 CollectibleRevisionID int64
@ -71,7 +150,10 @@ type StarGiftCollectibleAttribute struct {
EdgeColor int EdgeColor int
PatternColor int PatternColor int
TextColor int TextColor int
RarityKind StarGiftAttributeRarityKind
RarityPermille int RarityPermille int
Crafted bool
OfficialDocumentID int64
SortOrder int SortOrder int
Animation *StarGiftAnimation Animation *StarGiftAnimation
Blob *FileBlob Blob *FileBlob
@ -79,33 +161,37 @@ type StarGiftCollectibleAttribute struct {
// StarGiftCollectibleRevision 是某普通礼物的一份不可变、可发布属性池。 // StarGiftCollectibleRevision 是某普通礼物的一份不可变、可发布属性池。
type StarGiftCollectibleRevision struct { type StarGiftCollectibleRevision struct {
ID int64 ID int64
GiftID int64 GiftID int64
Revision int Revision int
UpgradeStars int64 UpgradeStars int64
SupplyTotal int SupplyTotal int
Issued int Issued int
SlugPrefix string SlugPrefix string
Published bool Published bool
Models []StarGiftCollectibleAttribute Models []StarGiftCollectibleAttribute
Patterns []StarGiftCollectibleAttribute Patterns []StarGiftCollectibleAttribute
Backdrops []StarGiftCollectibleAttribute Backdrops []StarGiftCollectibleAttribute
CreatedBy string CreatedBy string
CreatedAt time.Time CreatedAt time.Time
PublishedAt time.Time PublishedAt time.Time
OfficialGiftID int64
SourceManifestSHA256 []byte
} }
// StarGiftCollectibleWrite 是后台创建/发布属性池的协议无关输入。 // StarGiftCollectibleWrite 是后台创建/发布属性池的协议无关输入。
type StarGiftCollectibleWrite struct { type StarGiftCollectibleWrite struct {
GiftID int64 GiftID int64
UpgradeStars int64 UpgradeStars int64
SupplyTotal int SupplyTotal int
SlugPrefix string SlugPrefix string
Models []StarGiftCollectibleAttribute Models []StarGiftCollectibleAttribute
Patterns []StarGiftCollectibleAttribute Patterns []StarGiftCollectibleAttribute
Backdrops []StarGiftCollectibleAttribute Backdrops []StarGiftCollectibleAttribute
Actor string Actor string
CommandID string CommandID string
OfficialGiftID int64
SourceManifestSHA256 []byte
} }
// UniqueStarGift 是一份已经发行的唯一礼物。属性、编号与 slug 一经创建永久不变。 // UniqueStarGift 是一份已经发行的唯一礼物。属性、编号与 slug 一经创建永久不变。
@ -118,6 +204,26 @@ type UniqueStarGift struct {
Slug string Slug string
Num int Num int
Owner Peer 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 Model StarGiftCollectibleAttribute
Pattern StarGiftCollectibleAttribute Pattern StarGiftCollectibleAttribute
Backdrop StarGiftCollectibleAttribute Backdrop StarGiftCollectibleAttribute
@ -132,6 +238,33 @@ type UniqueStarGift struct {
CreatedAt time.Time 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 是客户端升级弹窗所需的当前价格和属性样例。 // StarGiftUpgradePreview 是客户端升级弹窗所需的当前价格和属性样例。
type StarGiftUpgradePreview struct { type StarGiftUpgradePreview struct {
GiftID int64 GiftID int64
@ -171,7 +304,205 @@ type StarGiftUpgradeRequest struct {
OriginSessionID int64 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 { 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 Saved SavedStarGift
Unique UniqueStarGift Unique UniqueStarGift
Balance StarsBalance Balance StarsBalance
@ -179,6 +510,161 @@ type StarGiftUpgradeResult struct {
Duplicate bool 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 资料页中的礼物集合;一份礼物可属于多个集合。 // StarGiftCollection 是 peer 资料页中的礼物集合;一份礼物可属于多个集合。
type StarGiftCollection struct { type StarGiftCollection struct {
Owner Peer Owner Peer
@ -223,17 +709,53 @@ type StarGiftAnimation struct {
// StarGiftCatalogWrite 是 store 原子创建目录版本所需的协议无关数据。 // StarGiftCatalogWrite 是 store 原子创建目录版本所需的协议无关数据。
type StarGiftCatalogWrite struct { type StarGiftCatalogWrite struct {
GiftID int64 // 0 创建新礼物;非 0 为该礼物创建新 revision GiftID int64 // 0 创建新礼物;非 0 为该礼物创建新 revision
Title string Title string
Stars int64 Stars int64
ConvertStars int64 ConvertStars int64
Enabled bool Enabled bool
SortOrder int SortOrder int
Document Document Document Document
Blob FileBlob Blob FileBlob
Animation StarGiftAnimation Animation StarGiftAnimation
Actor string Actor string
CommandID 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 是管理后台目录视图。 // StarGiftCatalogEntry 是管理后台目录视图。
@ -255,20 +777,24 @@ type StarGiftCatalogEntry struct {
} }
// SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。 // SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。
// 用户礼物使用 inputSavedStarGiftUser.msg_id频道礼物使用 inputSavedStarGiftChat.peer + saved_id。 // 用户礼物使用 inputSavedStarGiftUser.msg_id频道礼物使用 inputSavedStarGiftChat.peer + saved_id
// 已升级的唯一礼物也可使用官方 inputSavedStarGiftSlug.slug。三种身份必须互斥。
type SavedStarGiftRef struct { type SavedStarGiftRef struct {
Owner Peer Owner Peer
MsgID int MsgID int
SavedID int64 SavedID int64
Slug string
} }
// Valid reports whether the reference has the identity required by its owner kind. // Valid reports whether the reference has the identity required by its owner kind.
func (r SavedStarGiftRef) Valid() bool { 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 { switch r.Owner.Type {
case PeerTypeUser: 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: case PeerTypeChannel:
return r.Owner.ID != 0 && r.SavedID > 0 return r.Owner.ID != 0 && (validSlug || r.SavedID > 0 && r.MsgID == 0 && slug == "")
default: default:
return false return false
} }
@ -317,7 +843,8 @@ const (
// MaxStarGiftCatalogSize 是当前普通礼物目录的有界上限。 // MaxStarGiftCatalogSize 是当前普通礼物目录的有界上限。
MaxStarGiftCatalogSize = 500 MaxStarGiftCatalogSize = 500
MaxStarGiftTitleRunes = 128 MaxStarGiftTitleRunes = 128
MaxStarGiftCollectibleAttributesPerKind = 256 MaxStarGiftSlugBytes = 255
MaxStarGiftCollectibleAttributesPerKind = 512
MaxStarGiftCollectionTitleRunes = 12 MaxStarGiftCollectionTitleRunes = 12
MaxStarGiftCollectionsPerPeer = 100 MaxStarGiftCollectionsPerPeer = 100
MaxStarGiftCollectionItems = 1000 MaxStarGiftCollectionItems = 1000
@ -339,6 +866,18 @@ var (
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition") ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found") ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
ErrStarGiftCollectionsFull = errors.New("stargift: collections full") 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}$`) 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) == "" { !starGiftCollectibleSlugPrefix.MatchString(write.SlugPrefix) || strings.TrimSpace(write.CommandID) == "" {
return ErrStarGiftCollectibleInvalid 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 { if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, false); err != nil {
return err return err
} }
@ -380,11 +924,19 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
return ErrStarGiftCollectibleInvalid return ErrStarGiftCollectibleInvalid
} }
seen := make(map[string]struct{}, len(attributes)) seen := make(map[string]struct{}, len(attributes))
total := 0 selectable := 0
for _, attribute := range attributes { for _, attribute := range attributes {
name := strings.TrimSpace(attribute.Name) name := strings.TrimSpace(attribute.Name)
if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes || rarityKind := attribute.RarityKind
attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 { 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 return ErrStarGiftCollectibleInvalid
} }
key := strings.ToLower(name) key := strings.ToLower(name)
@ -392,7 +944,6 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
return ErrStarGiftCollectibleInvalid return ErrStarGiftCollectibleInvalid
} }
seen[key] = struct{}{} seen[key] = struct{}{}
total += attribute.RarityPermille
switch kind { switch kind {
case StarGiftCollectibleModel, StarGiftCollectiblePattern: case StarGiftCollectibleModel, StarGiftCollectiblePattern:
if attribute.Animation == nil || len(attribute.Animation.JSON) == 0 || if attribute.Animation == nil || len(attribute.Animation.JSON) == 0 ||
@ -404,7 +955,7 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
return ErrStarGiftCollectibleInvalid return ErrStarGiftCollectibleInvalid
} }
case StarGiftCollectibleBackdrop: case StarGiftCollectibleBackdrop:
if attribute.BackdropID <= 0 || attribute.Document != nil || if attribute.BackdropID < 0 || attribute.Document != nil ||
attribute.CenterColor < 0 || attribute.CenterColor > 0xffffff || attribute.CenterColor < 0 || attribute.CenterColor > 0xffffff ||
attribute.EdgeColor < 0 || attribute.EdgeColor > 0xffffff || attribute.EdgeColor < 0 || attribute.EdgeColor > 0xffffff ||
attribute.PatternColor < 0 || attribute.PatternColor > 0xffffff || attribute.PatternColor < 0 || attribute.PatternColor > 0xffffff ||
@ -415,7 +966,7 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
return ErrStarGiftCollectibleInvalid return ErrStarGiftCollectibleInvalid
} }
} }
if total != 1000 { if selectable == 0 {
return ErrStarGiftCollectibleInvalid return ErrStarGiftCollectibleInvalid
} }
return nil return nil

View file

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

View file

@ -20,13 +20,19 @@ type StarsBalance struct {
type StarsTransactionReason string type StarsTransactionReason string
const ( const (
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予 StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造) StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费 StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取 StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物 StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁 StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer"
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整 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。 // StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0含 refund/收取),借记 < 0。
@ -52,6 +58,28 @@ type StarsTransactionPage struct {
Users []User // History 中提到的对手方用户,供 tg Users 富化 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 账本边界常量。 // Stars 账本边界常量。
const ( const (
// DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。 // DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。

View file

@ -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 &copy
}
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
}

View file

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

View file

@ -284,6 +284,8 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
} }
case domain.ChannelActionStarGift: case domain.ChannelActionStarGift:
return tgMessageActionStarGift(action.StarGift) return tgMessageActionStarGift(action.StarGift)
case domain.ChannelActionStarGiftUnique:
return tgMessageActionStarGiftUnique(action.StarGiftUnique)
case domain.ChannelActionSetChatWallpaper: case domain.ChannelActionSetChatWallpaper:
if wallpaper := tgWallpaper(action.Wallpaper); wallpaper != nil { if wallpaper := tgWallpaper(action.Wallpaper); wallpaper != nil {
return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper} return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper}

View file

@ -221,29 +221,69 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
case domain.MessageServiceActionStarGift: case domain.MessageServiceActionStarGift:
return tgMessageActionStarGift(m.ServiceAction.StarGift) return tgMessageActionStarGift(m.ServiceAction.StarGift)
case domain.MessageServiceActionStarGiftUnique: case domain.MessageServiceActionStarGiftUnique:
action := m.ServiceAction.StarGiftUnique return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
case domain.MessageServiceActionStarGiftOffer:
action := m.ServiceAction.StarGiftOffer
if action == nil { if action == nil {
return &tg.MessageActionEmpty{} return &tg.MessageActionEmpty{}
} }
out := &tg.MessageActionStarGiftUnique{ return &tg.MessageActionStarGiftPurchaseOffer{Accepted: action.Accepted, Declined: action.Declined,
Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade, Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price), ExpiresAt: action.ExpiresAt}
Gift: tgUniqueStarGift(action.Gift), case domain.MessageServiceActionStarGiftOfferDeclined:
action := m.ServiceAction.StarGiftOfferDeclined
if action == nil {
return &tg.MessageActionEmpty{}
} }
if action.FromUserID != 0 { return &tg.MessageActionStarGiftPurchaseOfferDeclined{Expired: action.Expired,
out.SetFromID(&tg.PeerUser{UserID: action.FromUserID}) Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price)}
}
if peer := tgPeer(action.Peer); peer != nil {
out.SetPeer(peer)
}
if action.SavedID != 0 {
out.SetSavedID(action.SavedID)
}
return out
default: default:
return &tg.MessageActionEmpty{} 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 { func tgPeerList(peers []domain.Peer) []tg.PeerClass {
out := make([]tg.PeerClass, 0, len(peers)) out := make([]tg.PeerClass, 0, len(peers))
for _, peer := range peers { for _, peer := range peers {

View file

@ -872,6 +872,7 @@ type GiftsService interface {
UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error)
UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error)
Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, 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) 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) 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) 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) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error)
CountSaved(ctx context.Context, owner domain.Peer) (int, error) CountSaved(ctx context.Context, owner domain.Peer) (int, error)
ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, 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) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error)
CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (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) 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) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error)
ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error
SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) 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余额查询、贷记/借记、流水分页。 // StarsService 抽象 Stars 本地账本app/stars余额查询、贷记/借记、流水分页。

View file

@ -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 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 suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") }
func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") } func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") }

View file

@ -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) { registerRPC[*tg.PaymentsGetStarsTransactionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTransactions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTransactionsRequest) (any, error) {
return r.onPaymentsGetStarsTransactions(ctx, layerRequest) 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) { registerRPC[*tg.PaymentsGetStarGiftActiveAuctionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftActiveAuctions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftActiveAuctionsRequest) (any, error) {
hash := layerRequest. return r.onPaymentsGetStarGiftActiveAuctions(ctx, layerRequest)
Hash
_ = hash
return tdesktop.StarGiftActiveAuctions(), nil
}) })
registerRPC[*tg.PaymentsGetStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftsRequest) (any, error) { registerRPC[*tg.PaymentsGetStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftsRequest) (any, error) {
return r.onPaymentsGetStarGifts(ctx, layerRequest. return r.onPaymentsGetStarGifts(ctx, layerRequest.
@ -48,10 +47,19 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
return r.onPaymentsGetStarGiftUpgradePreview(ctx, layerRequest. return r.onPaymentsGetStarGiftUpgradePreview(ctx, layerRequest.
GiftID) 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) { registerRPC[*tg.PaymentsGetUniqueStarGiftRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGift, func(ctx context.Context, layerRequest *tg.PaymentsGetUniqueStarGiftRequest) (any, error) {
return r.onPaymentsGetUniqueStarGift(ctx, layerRequest. return r.onPaymentsGetUniqueStarGift(ctx, layerRequest.
Slug) 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) { registerRPC[*tg.PaymentsGetPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsGetPaymentForm, func(ctx context.Context, layerRequest *tg.PaymentsGetPaymentFormRequest) (any, error) {
return r.onPaymentsGetPaymentForm(ctx, layerRequest) 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) { registerRPC[*tg.PaymentsUpgradeStarGiftRequest](d, tlprofile.SemanticMethodPaymentsUpgradeStarGift, func(ctx context.Context, layerRequest *tg.PaymentsUpgradeStarGiftRequest) (any, error) {
return r.onPaymentsUpgradeStarGift(ctx, layerRequest) 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) { registerRPC[*tg.PaymentsGetStarGiftCollectionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftCollections, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftCollectionsRequest) (any, error) {
return r.onPaymentsGetStarGiftCollections(ctx, layerRequest) 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 return &tg.PaymentsStarsRevenueAdsAccountURL{URL: "https://ads.telegram.org/"}, nil
}) })
registerRPC[*tg.PaymentsGetStarsRevenueStatsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueStats, func(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (any, error) { registerRPC[*tg.PaymentsGetStarsRevenueStatsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueStats, func(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (any, error) {
userID, _, err := r.currentUserID(ctx) return r.onPaymentsGetStarsRevenueStats(ctx, req)
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
}) })
} }
// onPaymentsGetStarsStatus 返回当前账号的 Stars 余额(首读时惰性授予起始余额)。 // onPaymentsGetStarsRevenueStats exposes real channel Star Gift proceeds from
// 响应必须是 payments.starsStatusbalance/chats/users 都是必填,空 vector 即可)—— // the same peer-scoped ledger as getStarsStatus/getStarsTransactions. Personal
// 两端客户端无条件读取 balanceDrKLO StarsAmount 反序列化 / TDesktop vbalance())。 // and bot revenue remain the bounded compatibility response because their
func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) { // revenue bucket is distinct from the general Stars balance and is not modeled.
if req != nil && req.GetTon() { func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (*tg.PaymentsStarsRevenueStats, error) {
// TON 余额未建模:返回 0 nanoton 的合法响应。
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
userID, _, err := r.currentUserID(ctx) userID, _, err := r.currentUserID(ctx)
if err != nil { if err != nil {
return nil, internalErr() 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.starsStatusbalance/chats/users 都是必填,空 vector 即可)——
// 两端客户端无条件读取 balanceDrKLO 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) bal, err := r.deps.Stars.GetBalance(ctx, userID)
if err != nil { if err != nil {
return nil, starsErr(err) return nil, starsErr(err)
@ -148,24 +266,82 @@ func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsG
// onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。 // onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。
// 末页必须省略 next_offsetflag 不置),否则 DrKLO 会无限翻页。 // 末页必须省略 next_offsetflag 不置),否则 DrKLO 会无限翻页。
func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) { func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) {
if req != nil && req.GetTon() { userID, owner, err := r.starGiftTransactionLedgerOwner(ctx, req)
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil { if err != nil {
return nil, internalErr() return nil, err
} }
offset := "" offset, limit := "", domain.MaxStarsTransactionsLimit
limit := domain.MaxStarsTransactionsLimit
if req != nil { if req != nil {
offset = req.Offset offset = req.Offset
if req.Limit > 0 { if req.Limit > 0 {
limit = req.Limit 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) page, err := r.deps.Stars.ListTransactions(ctx, userID, offset, limit)
if err != nil { if err != nil {
return nil, starsErr(err) return nil, starsErr(err)
@ -184,6 +360,71 @@ func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.Pay
return out, nil 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.starsStatuschats/users 非空 vector 但可空)。 // emptyStarsStatus 构造一个合法的最小 payments.starsStatuschats/users 非空 vector 但可空)。
func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus { func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus {
return &tg.PaymentsStarsStatus{ return &tg.PaymentsStarsStatus{
@ -214,6 +455,49 @@ func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction {
item.Reaction = true item.Reaction = true
case domain.StarsReasonGift: case domain.StarsReasonGift:
item.Gift = true 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) out = append(out, item)
} }

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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) { 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 { if err != nil {
return nil, err return nil, err
} }
wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails) commandKey := fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails)
if formID == 0 || formID != wantFormID { receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey)
return nil, starsFormAmountMismatchErr() 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{ 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: inv.KeepOriginalDetails, ChargeStars: preview.UpgradeStars, KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: chargeStars,
FormID: formID, CommandKey: fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails), FormID: formID, CommandKey: commandKey,
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx), OriginSessionID: sessionIDOrZero(ctx),
}) })
@ -57,17 +76,32 @@ func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.Payments
if err != nil { if err != nil {
return nil, internalErr() return nil, internalErr()
} }
saved, _, err := r.starGiftUpgradeTarget(ctx, userID, req.Stargift) saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, req.Stargift)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if saved.PrepaidUpgradeStars <= 0 { commandKey := fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails)
return nil, starGiftInvalidErr() 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{ 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, 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), Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(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) { func (r *Router) starGiftUpgradeTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, domain.StarGiftUpgradePreview, error) {
if r.deps.Gifts == nil { saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, input)
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, notImplementedErr()
}
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
if err != nil { if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, err return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, err
} }
if !ok || ref.Owner.Type != domain.PeerTypeUser || ref.Owner.ID != userID { preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved)
// Channel gift upgrades require a channel pts aggregate and are not silently return saved, preview, err
// routed through the private-message transaction. }
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
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) saved, found, err := r.deps.Gifts.GetSaved(ctx, ref)
if err != nil { if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr() return domain.SavedStarGift{}, internalErr()
} }
if !found || saved.Converted || saved.UniqueGiftID != 0 { if !found {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr() 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) preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, saved.GiftID)
if err != nil { if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr() return domain.StarGiftUpgradePreview{}, internalErr()
} }
if !found || preview.UpgradeStars <= 0 || preview.Issued >= preview.SupplyTotal { 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 { 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, updates := tgPrivateMessageUpdates(event, message, 0, false,
r.usersForMessageUpdate(ctx, ownerUserID, message), r.usersForMessageUpdate(ctx, ownerUserID, message),
r.chatsForMessageUpdate(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 { if includeBalance {
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.Balance.Balance}}) 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 }, 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) { func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (*tg.PaymentsUniqueStarGift, error) {
if r.deps.Gifts == nil || strings.TrimSpace(slug) == "" { if r.deps.Gifts == nil || strings.TrimSpace(slug) == "" {
return nil, starGiftInvalidErr() return nil, starGiftInvalidErr()
@ -211,6 +297,9 @@ func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (
func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass { func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops)) out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
for _, attribute := range preview.Models { for _, attribute := range preview.Models {
if attribute.Crafted {
continue
}
out = append(out, tgStarGiftAttribute(attribute)) out = append(out, tgStarGiftAttribute(attribute))
} }
for _, attribute := range preview.Patterns { for _, attribute := range preview.Patterns {
@ -222,15 +311,25 @@ func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.Sta
return out 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 { func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeClass {
rarity := &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille} rarity := tgStarGiftAttributeRarity(attribute)
switch attribute.Kind { switch attribute.Kind {
case domain.StarGiftCollectibleModel: case domain.StarGiftCollectibleModel:
document := tg.DocumentClass(&tg.DocumentEmpty{}) document := tg.DocumentClass(&tg.DocumentEmpty{})
if attribute.Document != nil { if attribute.Document != nil {
document = tgDocument(*attribute.Document) 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: case domain.StarGiftCollectiblePattern:
document := tg.DocumentClass(&tg.DocumentEmpty{}) document := tg.DocumentClass(&tg.DocumentEmpty{})
if attribute.Document != nil { 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 { func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
attributes := []tg.StarGiftAttributeClass{ attributes := []tg.StarGiftAttributeClass{
tgStarGiftAttribute(unique.Model), tgStarGiftAttribute(unique.Model),
@ -268,11 +382,73 @@ func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
attributes = append(attributes, original) attributes = append(attributes, original)
} }
out := &tg.StarGiftUnique{ 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, ID: unique.ID, GiftID: unique.GiftID, Title: unique.Title, Slug: unique.Slug, Num: unique.Num,
Attributes: attributes, AvailabilityIssued: unique.AvailabilityIssued, AvailabilityTotal: unique.AvailabilityTotal, 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) 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 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
}
}

View file

@ -2,7 +2,11 @@ package rpc
import ( import (
"context" "context"
"crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt"
"strings"
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr" "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 { if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.starGiftUpgradePaymentForm(ctx, userID, inv) 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) inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok { if !ok {
@ -92,16 +111,13 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
if err != nil { if err != nil {
return nil, err 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) gift, err := r.starGiftFromCatalog(ctx, inv.GiftID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if gift.RequirePremium && !r.viewerPremium(ctx, userID) {
return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
}
upgradeStars := int64(0) upgradeStars := int64(0)
if inv.IncludeUpgrade { if inv.IncludeUpgrade {
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal { 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 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{ return &tg.PaymentsPaymentFormStarGift{
FormID: starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade), FormID: form.FormID,
Invoice: tg.Invoice{ Invoice: tg.Invoice{
Currency: "XTR", Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: giftPriceLabel(gift), Amount: gift.Stars + upgradeStars}}, 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 { if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.sendStarGiftUpgradeForm(ctx, userID, req.FormID, inv) 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) inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok { if !ok {
return nil, notImplementedErr() return nil, notImplementedErr()
} }
if req.FormID == 0 {
return nil, formIDEmptyErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer) peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
if err != nil { if err != nil {
return nil, err 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 { if (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) || peer.ID == 0 {
return nil, peerIDInvalidErr() return nil, peerIDInvalidErr()
} }
if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser {
return nil, starGiftInvalidErr()
}
if r.deps.Stars == nil || r.deps.Gifts == nil { if r.deps.Stars == nil || r.deps.Gifts == nil {
return nil, notImplementedErr() return nil, notImplementedErr()
} }
if peer.Type == domain.PeerTypeUser && r.deps.Messages == nil {
return nil, notImplementedErr()
}
if peer.Type == domain.PeerTypeChannel && r.deps.Channels == nil { if peer.Type == domain.PeerTypeChannel && r.deps.Channels == nil {
return nil, notImplementedErr() return nil, notImplementedErr()
} }
@ -167,6 +208,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if err != nil { if err != nil {
return nil, err return nil, err
} }
buyerPremium := r.viewerPremium(ctx, userID)
if gift.RequirePremium && !buyerPremium {
return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
}
upgradeStars := int64(0) upgradeStars := int64(0)
if inv.IncludeUpgrade { if inv.IncludeUpgrade {
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal { 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 upgradeStars = gift.UpgradeStars
} }
if req.FormID != starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade) {
return nil, starsFormAmountMismatchErr()
}
giftMessage := "" giftMessage := ""
if m, ok := inv.GetMessage(); ok { if m, ok := inv.GetMessage(); ok {
giftMessage = clampGiftMessage(m.Text) 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 purchaseStars := gift.Stars + upgradeStars
balance, err := r.deps.Stars.Debit(ctx, userID, purchaseStars, domain.StarsReasonGift, peer, "Star gift", gift.Title) balance, err := r.deps.Stars.Debit(ctx, userID, purchaseStars, domain.StarsReasonGift, peer, "Star gift", gift.Title)
if err != nil { if err != nil {
return nil, starsErr(err) return nil, starsErr(err)
} }
var updates *tg.Updates var updates *tg.Updates
switch peer.Type { switch peer.Type {
case domain.PeerTypeUser: case domain.PeerTypeUser:
updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars) updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
case domain.PeerTypeChannel: 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: default:
err = domain.ErrStarGiftInvalid err = domain.ErrStarGiftInvalid
} }
@ -202,18 +278,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
r.refundStarGift(ctx, userID, peer, gift, purchaseStars) r.refundStarGift(ctx, userID, peer, gift, purchaseStars)
return nil, internalErr() return nil, internalErr()
} }
if updates == nil {
// 4. 构建送礼人 Updates服务消息 + updateStarsBalance updates = emptyGiftUpdates(r.clock.Now().Unix())
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()),
}
} }
appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, balance.Balance)
return &tg.PaymentsPaymentResult{Updates: updates}, nil 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) { 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. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。 // 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 { if err != nil {
return nil, err return nil, err
} }
@ -312,6 +388,7 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
Unsaved: false, Unsaved: false,
ConvertStars: gift.ConvertStars, ConvertStars: gift.ConvertStars,
PrepaidUpgradeStars: prepaidUpgradeStars, PrepaidUpgradeStars: prepaidUpgradeStars,
PrepaidUpgradeHash: prepaidUpgradeHash,
Message: message, Message: message,
}); err != nil { }); err != nil {
return nil, err 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 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()) now := int(r.clock.Now().Unix())
sticker := gift.Sticker sticker := gift.Sticker
action := domain.ChannelMessageAction{ action := domain.ChannelMessageAction{
Type: domain.ChannelActionStarGift, Type: domain.ChannelActionStarGift,
StarGift: &domain.MessageStarGiftAction{ StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID, GiftID: gift.ID,
Stars: gift.Stars, Stars: gift.Stars,
ConvertStars: gift.ConvertStars, ConvertStars: gift.ConvertStars,
Title: gift.Title, Title: gift.Title,
Sticker: &sticker, Sticker: &sticker,
Message: message, Message: message,
FromUserID: senderID, FromUserID: senderID,
NameHidden: hideName, NameHidden: hideName,
Saved: true, Saved: true,
CanUpgrade: false, CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: false, PrepaidUpgrade: prepaidUpgradeStars > 0,
UpgradeStars: 0, UpgradePriceStars: gift.UpgradeStars,
UpgradeStars: prepaidUpgradeStars,
}, },
} }
savedID, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{ savedID, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
FromUserID: senderID, FromUserID: senderID,
GiftID: gift.ID, GiftID: gift.ID,
RevisionID: gift.RevisionID, RevisionID: gift.RevisionID,
MsgID: 0, MsgID: 0,
SavedID: 0, SavedID: 0,
Date: now, Date: now,
NameHidden: hideName, NameHidden: hideName,
Unsaved: false, Unsaved: false,
ConvertStars: gift.ConvertStars, ConvertStars: gift.ConvertStars,
Message: message, PrepaidUpgradeStars: prepaidUpgradeStars,
Message: message,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -375,7 +454,7 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
} }
// deliverStarGift 经 SendPrivateText 把 messageActionStarGift 服务消息投递到收礼人私聊。 // 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) recipientBlocked, err := r.peerBlocksUser(ctx, senderID, recipientID)
if err != nil { if err != nil {
return domain.SendPrivateTextResult{}, err return domain.SendPrivateTextResult{}, err
@ -387,19 +466,21 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6
ServiceAction: &domain.MessageServiceAction{ ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{ StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID, GiftID: gift.ID,
Stars: gift.Stars, Stars: gift.Stars,
ConvertStars: gift.ConvertStars, ConvertStars: gift.ConvertStars,
Title: gift.Title, Title: gift.Title,
Sticker: &sticker, Sticker: &sticker,
Message: message, Message: message,
FromUserID: senderID, FromUserID: senderID,
PeerUserID: recipientID, PeerUserID: recipientID,
NameHidden: hideName, NameHidden: hideName,
Saved: true, Saved: true,
CanUpgrade: gift.UpgradeStars > 0, CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: prepaidUpgradeStars > 0, PrepaidUpgrade: prepaidUpgradeStars > 0,
UpgradeStars: gift.UpgradeStars, PrepaidUpgradeHash: prepaidUpgradeHash,
UpgradePriceStars: gift.UpgradeStars,
UpgradeStars: prepaidUpgradeStars,
}, },
}, },
} }
@ -529,13 +610,15 @@ func (r *Router) onPaymentsSaveStarGift(ctx context.Context, req *tg.PaymentsSav
return true, nil return true, nil
} }
// onPaymentsConvertStarGift 把收到的礼物转换回 StarsCredit + 标记 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) { func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSavedStarGiftClass) (bool, error) {
userID, _, err := r.currentUserID(ctx) userID, _, err := r.currentUserID(ctx)
if err != nil { if err != nil {
return false, internalErr() return false, internalErr()
} }
if r.deps.Gifts == nil || r.deps.Stars == nil { if r.deps.Gifts == nil {
return false, notImplementedErr() return false, notImplementedErr()
} }
dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref) dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref)
@ -545,10 +628,42 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
if !ok { if !ok {
return false, starGiftInvalidErr() return false, starGiftInvalidErr()
} }
if dref.Owner.Type == domain.PeerTypeChannel { if err := r.ensureCanManageStarGiftOwner(ctx, userID, dref.Owner); err != nil {
return false, notImplementedErr() 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 { if err != nil {
switch { switch {
case errors.Is(err, domain.ErrStarGiftNotFound): case errors.Is(err, domain.ErrStarGiftNotFound):
@ -559,15 +674,8 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
return false, internalErr() 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 投影。 // 转换移除一份展示礼物 → 失效 owner full 投影。
r.invalidateStarGiftOwnerProjection(dref.Owner) r.invalidateStarGiftOwnerProjection(result.Saved.Owner)
return true, nil 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{}, false, peerIDInvalidErr()
} }
return domain.SavedStarGiftRef{Owner: owner, SavedID: v.SavedID}, true, nil 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: default:
return domain.SavedStarGiftRef{}, false, nil return domain.SavedStarGiftRef{}, false, nil
} }
@ -718,11 +844,6 @@ func (r *Router) resolveStarGiftCollectibleAvailability(ctx context.Context, gif
if gift.UniqueGiftID != 0 { if gift.UniqueGiftID != 0 {
continue 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 { if _, ok := seen[gift.GiftID]; ok {
continue continue
} }
@ -755,10 +876,24 @@ func tgStarGifts(catalog []domain.StarGift) []tg.StarGiftClass {
// tgStarGift 把目录项投影为 tg.StarGiftSticker 须为带 sticker 属性的有效 Document // tgStarGift 把目录项投影为 tg.StarGiftSticker 须为带 sticker 属性的有效 Document
func tgStarGift(g domain.StarGift) *tg.StarGift { func tgStarGift(g domain.StarGift) *tg.StarGift {
gift := &tg.StarGift{ gift := &tg.StarGift{
ID: g.ID, Limited: g.Limited, SoldOut: g.SoldOut, Birthday: g.Birthday,
Sticker: tgDocument(g.Sticker), RequirePremium: g.RequirePremium, LimitedPerUser: g.LimitedPerUser,
Stars: g.Stars, PeerColorAvailable: g.PeerColorAvailable, Auction: g.Auction,
ConvertStars: g.ConvertStars, 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 != "" { if g.Title != "" {
gift.SetTitle(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 { if g.UpgradeStars > 0 && g.UpgradeIssued < g.UpgradeTotal {
gift.SetUpgradeStars(g.UpgradeStars) 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 return gift
} }
@ -787,6 +947,9 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
if in.Title != "" { if in.Title != "" {
gift.SetTitle(in.Title) gift.SetTitle(in.Title)
} }
if in.UpgradePriceStars > 0 {
gift.SetUpgradeStars(in.UpgradePriceStars)
}
action := &tg.MessageActionStarGift{Gift: gift} action := &tg.MessageActionStarGift{Gift: gift}
if in.NameHidden { if in.NameHidden {
action.NameHidden = true action.NameHidden = true
@ -799,12 +962,26 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
} }
action.CanUpgrade = in.CanUpgrade action.CanUpgrade = in.CanUpgrade
action.PrepaidUpgrade = in.PrepaidUpgrade action.PrepaidUpgrade = in.PrepaidUpgrade
action.UpgradeSeparate = in.UpgradeSeparate
action.AuctionAcquired = in.AuctionAcquired
if in.UpgradeStars > 0 { if in.UpgradeStars > 0 {
action.SetUpgradeStars(in.UpgradeStars) action.SetUpgradeStars(in.UpgradeStars)
} }
if in.UpgradeMsgID > 0 { if in.UpgradeMsgID > 0 {
action.SetUpgradeMsgID(in.UpgradeMsgID) 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 { if in.ConvertStars > 0 {
action.SetConvertStars(in.ConvertStars) action.SetConvertStars(in.ConvertStars)
} }
@ -889,6 +1066,9 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
item.SetUpgradeStars(g.PrepaidUpgradeStars) item.SetUpgradeStars(g.PrepaidUpgradeStars)
item.CanUpgrade = true item.CanUpgrade = true
} }
if g.PrepaidUpgradeHash != "" && g.PrepaidUpgradeStars == 0 && canIssue {
item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash)
}
} }
if g.PinnedOrder > 0 { if g.PinnedOrder > 0 {
item.PinnedToTop = true item.PinnedToTop = true
@ -898,6 +1078,8 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
} }
if g.Unique != nil { if g.Unique != nil {
item.SetGiftNum(g.Unique.Num) item.SetGiftNum(g.Unique.Num)
} else if g.GiftNum > 0 {
item.SetGiftNum(g.GiftNum)
} }
out = append(out, item) out = append(out, item)
} }
@ -944,24 +1126,6 @@ func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 {
return ids 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 { func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 {
id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253 id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253
for _, ch := range currency { for _, ch := range currency {

View file

@ -21,6 +21,10 @@ import (
) )
func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) { 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() t.Helper()
ctx := context.Background() ctx := context.Background()
users := memory.NewUserStore() users := memory.NewUserStore()
@ -36,7 +40,7 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
t.Fatalf("create recipient: %v", err) t.Fatalf("create recipient: %v", err)
} }
gift := domain.StarGift{ 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}}}, Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
} }
giftStore := memory.NewStarGiftStore() giftStore := memory.NewStarGiftStore()
@ -52,6 +56,33 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
return r, sender, recipient, gift 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 { type uniqueGiftRPCService struct {
GiftsService GiftsService
unique domain.UniqueStarGift unique domain.UniqueStarGift
@ -61,8 +92,139 @@ func (s *uniqueGiftRPCService) UniqueBySlug(_ context.Context, slug string) (dom
return s.unique, slug == s.unique.Slug, nil 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 { 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 { if kind == domain.StarGiftCollectibleBackdrop {
attribute.BackdropID = int(id) attribute.BackdropID = int(id)
attribute.CenterColor = 0x112233 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) { func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *testing.T) {
r, sender, owner, gift := starGiftTestRouter(t) r, sender, owner, gift := starGiftTestRouter(t)
ctx := context.Background() ctx := context.Background()
@ -157,11 +433,15 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
t.Fatalf("gift service = %T", r.deps.Gifts) t.Fatalf("gift service = %T", r.deps.Gifts)
} }
model := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8101, "Aurora") 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") pattern := collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8102, "Orbit")
backdrop := collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 1, "Midnight") backdrop := collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 1, "Midnight")
if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 500, SlugPrefix: "cake", 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", Backdrops: []domain.StarGiftCollectibleAttribute{backdrop}, Actor: "test", CommandID: "collectible-rpc",
}); err != nil { }); err != nil {
t.Fatalf("publish collectible pool: %v", err) t.Fatalf("publish collectible pool: %v", err)
@ -177,6 +457,17 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
if err != nil || len(preview.SampleAttributes) != 3 { if err != nil || len(preview.SampleAttributes) != 3 {
t.Fatalf("upgrade preview = %#v err %v", preview, err) 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}} invoice := &tg.InputInvoiceStarGiftUpgrade{Stargift: &tg.InputSavedStarGiftUser{MsgID: 444}}
formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice}) formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
if err != nil { if err != nil {
@ -208,7 +499,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
message := domain.Message{Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ message := domain.Message{Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique, Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{ 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) 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 { } else if user, ok := peer.(*tg.PeerUser); !ok || user.UserID != owner.ID {
t.Fatalf("unique service action peer = %#v", peer) 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} { for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
responseWire := &bin.Buffer{} responseWire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, uniqueResponse, responseWire); err != nil { if err := tlprofile.EncodeObject(profile, uniqueResponse, responseWire); err != nil {
@ -254,7 +548,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
if !ok { if !ok {
t.Fatalf("decode Layer %d unique action type = %T", profile, decodedActionObject) 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) t.Fatalf("Layer %d unique action lost fields: %#v", profile, decodedAction)
} }
} }
@ -556,10 +850,15 @@ func TestStarGiftChannelSaga(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("publish channel collectible pool: %v", err) 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, 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{ inv := &tg.InputInvoiceStarGift{
Peer: channelPeer, Peer: channelPeer,
@ -612,8 +911,8 @@ func TestStarGiftChannelSaga(t *testing.T) {
if savedRes.Count != 1 || len(savedRes.Gifts) != 1 { 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)) t.Fatalf("channel saved gifts = count %d len %d, want 1/1", savedRes.Count, len(savedRes.Gifts))
} }
if savedRes.Gifts[0].CanUpgrade { if !savedRes.Gifts[0].CanUpgrade {
t.Fatal("channel saved gift must not advertise upgrade while channel aggregate is blocked") t.Fatal("channel saved gift must advertise upgrade when a collectible pool is available")
} }
savedID, ok := savedRes.Gifts[0].GetSavedID() savedID, ok := savedRes.Gifts[0].GetSavedID()
if !ok || savedID <= 0 { if !ok || savedID <= 0 {
@ -728,8 +1027,12 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
}, zaptest.NewLogger(t), clock.System) }, zaptest.NewLogger(t), clock.System)
senderCtx := WithUserID(ctx, sender.ID) senderCtx := WithUserID(ctx, sender.ID)
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.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} formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: starGiftFormID(sender.ID, peer, gift), Invoice: inv}); err == nil { 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") 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) r, sender, recipient, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), sender.ID) 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} 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") { firstRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
t.Fatalf("bad form err=%v", err) 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))
} }
} }

View file

@ -98,3 +98,113 @@ func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) {
} }
_ = domain.DefaultStarsStartingGrant _ = 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")
}
}

View file

@ -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) { func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
return s.createCatalogRevisionLocked(write)
}
func (s *StarGiftStore) createCatalogRevisionLocked(write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
giftID := write.GiftID giftID := write.GiftID
if giftID == 0 { if giftID == 0 {
s.nextGiftID++ s.nextGiftID++
@ -104,7 +108,21 @@ func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.St
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftNotFound return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftNotFound
} }
s.nextRevID++ 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.catalog[giftID] = gift
s.revisions[gift.RevisionID] = gift s.revisions[gift.RevisionID] = gift
s.enabled[giftID] = write.Enabled 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 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 &copy
}
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) { func (s *StarGiftStore) SetCatalogEnabled(_ context.Context, giftID int64, enabled bool) (bool, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@ -148,6 +205,10 @@ func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write doma
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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 { if _, ok := s.catalog[write.GiftID]; !ok {
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftNotFound 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, ID: previous.ID + 1, GiftID: write.GiftID, Revision: previous.Revision + 1,
UpgradeStars: write.UpgradeStars, SupplyTotal: write.SupplyTotal, UpgradeStars: write.UpgradeStars, SupplyTotal: write.SupplyTotal,
SlugPrefix: strings.ToLower(strings.TrimSpace(write.SlugPrefix)), Published: true, 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 { if revision.ID == 1 {
revision.ID = write.GiftID*1000 + 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.SavedID = gift.ID
} }
gift.Converted = false gift.Converted = false
gift.LifecycleStatus = domain.StarGiftLifecycleActive
s.gifts = append(s.gifts, gift) s.gifts = append(s.gifts, gift)
return gift.ID, nil return gift.ID, nil
} }
@ -297,7 +360,7 @@ func (s *StarGiftStore) ListByOwnerFiltered(_ context.Context, filter domain.Sav
defer s.mu.Unlock() defer s.mu.Unlock()
matched := make([]domain.SavedStarGift, 0) matched := make([]domain.SavedStarGift, 0)
for _, g := range s.gifts { for _, g := range s.gifts {
if g.Owner != owner || g.Converted { if g.Owner != owner || !g.LifecycleStatus.Live() {
continue continue
} }
if filter.ExcludeUnsaved && g.Unsaved { if filter.ExcludeUnsaved && g.Unsaved {
@ -370,7 +433,7 @@ func (s *StarGiftStore) ResolveSavedIDs(_ context.Context, owner domain.Peer, re
} }
var id int64 var id int64
for _, gift := range s.gifts { for _, gift := range s.gifts {
if savedStarGiftMatchesRef(gift, ref) && !gift.Converted { if s.savedStarGiftMatchesRef(gift, ref) && gift.LifecycleStatus.Live() {
id = gift.ID id = gift.ID
break break
} }
@ -394,7 +457,7 @@ func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
for _, g := range s.gifts { for _, g := range s.gifts {
if savedStarGiftMatchesRef(g, ref) { if s.savedStarGiftMatchesRef(g, ref) {
return g, true, nil return g, true, nil
} }
} }
@ -409,7 +472,7 @@ func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int,
defer s.mu.Unlock() defer s.mu.Unlock()
n := 0 n := 0
for _, g := range s.gifts { for _, g := range s.gifts {
if g.Owner == owner && !g.Converted && !g.Unsaved { if g.Owner == owner && g.LifecycleStatus.Live() && !g.Unsaved {
n++ n++
} }
} }
@ -423,7 +486,7 @@ func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRe
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
for i := range s.gifts { 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 s.gifts[i].Unsaved = unsaved
return true, nil return true, nil
} }
@ -438,7 +501,7 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
for i := range s.gifts { for i := range s.gifts {
if savedStarGiftMatchesRef(s.gifts[i], ref) { if s.savedStarGiftMatchesRef(s.gifts[i], ref) {
if s.gifts[i].UniqueGiftID != 0 { if s.gifts[i].UniqueGiftID != 0 {
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyUpgraded return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyUpgraded
} }
@ -446,6 +509,7 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyConverted return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyConverted
} }
s.gifts[i].Converted = true s.gifts[i].Converted = true
s.gifts[i].LifecycleStatus = domain.StarGiftLifecycleConverted
s.gifts[i].Unsaved = true s.gifts[i].Unsaved = true
s.gifts[i].PinnedOrder = 0 s.gifts[i].PinnedOrder = 0
for collectionIndex := range s.collections[ref.Owner] { for collectionIndex := range s.collections[ref.Owner] {
@ -640,7 +704,7 @@ func (s *StarGiftStore) validCollectionGiftIDsLocked(owner domain.Peer, ids []in
} }
valid := false valid := false
for _, gift := range s.gifts { 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 valid = true
break break
} }
@ -707,6 +771,7 @@ func cloneCollectibleAttribute(in domain.StarGiftCollectibleAttribute) domain.St
func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.StarGiftCollectibleRevision { func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.StarGiftCollectibleRevision {
out := in out := in
out.SourceManifestSHA256 = append([]byte(nil), in.SourceManifestSHA256...)
clone := func(attributes []domain.StarGiftCollectibleAttribute) []domain.StarGiftCollectibleAttribute { clone := func(attributes []domain.StarGiftCollectibleAttribute) []domain.StarGiftCollectibleAttribute {
copy := make([]domain.StarGiftCollectibleAttribute, len(attributes)) copy := make([]domain.StarGiftCollectibleAttribute, len(attributes))
for i, attribute := range 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) 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 { if g.Owner != ref.Owner {
return false 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 { switch ref.Owner.Type {
case domain.PeerTypeUser: case domain.PeerTypeUser:
return g.MsgID == ref.MsgID return g.MsgID == ref.MsgID

View file

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

View file

@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -53,6 +55,22 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se
_ = tx.Rollback(ctx) _ = 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) channel, err := getChannelByID(ctx, tx, channelID)
if err != nil { if err != nil {
return err return err
@ -63,29 +81,14 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se
} }
action = channelServiceActionForMessage(channelID, messageID, action) action = channelServiceActionForMessage(channelID, messageID, action)
msg := domain.ChannelMessage{ msg := domain.ChannelMessage{
ChannelID: channelID, ChannelID: channelID, ID: messageID, SenderUserID: senderUserID,
ID: messageID, From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, Date: date,
SenderUserID: senderUserID, Post: channel.Broadcast, Action: &action, Pts: channel.Pts,
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{ return s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
ChannelID: channelID, ChannelID: channelID, UserID: senderUserID, Date: date,
UserID: senderUserID, Type: domain.ChannelAdminLogSendMessage, Message: &msg,
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
} }
func (s *ChannelStore) appendServiceMessage(ctx context.Context, label string, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) { func (s *ChannelStore) appendServiceMessage(ctx context.Context, label string, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) {

View file

@ -24,6 +24,16 @@ func NewStarGiftStore(db sqlcgen.DBTX) *StarGiftStore {
const starGiftCatalogSelect = ` const starGiftCatalogSelect = `
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title, 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), 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.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text 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, ` gift, err := scanCatalogGift(s.db.QueryRow(ctx, `
SELECT r.gift_id, r.id, r.stars, r.convert_stars, r.title, 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), 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.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text d.attributes::text, d.thumbs::text
@ -95,14 +115,34 @@ WHERE r.id = $1`, revisionID))
func scanCatalogGift(row rowScanner) (domain.StarGift, error) { func scanCatalogGift(row rowScanner) (domain.StarGift, error) {
var gift domain.StarGift var gift domain.StarGift
var attrsJSON, thumbsJSON string var attrsJSON, thumbsJSON string
var releasedByType string
var releasedByID int64
var hasBackground bool
var background domain.StarGiftBackground
if err := row.Scan( if err := row.Scan(
&gift.ID, &gift.RevisionID, &gift.Stars, &gift.ConvertStars, &gift.Title, &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.UpgradeStars, &gift.UpgradeTotal, &gift.UpgradeIssued,
&gift.Sticker.ID, &gift.Sticker.AccessHash, &gift.Sticker.FileReference, &gift.Sticker.Date, &gift.Sticker.ID, &gift.Sticker.AccessHash, &gift.Sticker.FileReference, &gift.Sticker.Date,
&gift.Sticker.MimeType, &gift.Sticker.Size, &gift.Sticker.DCID, &attrsJSON, &thumbsJSON, &gift.Sticker.MimeType, &gift.Sticker.Size, &gift.Sticker.DCID, &attrsJSON, &thumbsJSON,
); err != nil { ); err != nil {
return domain.StarGift{}, err 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) attrs, err := decodeDocumentAttributes(attrsJSON)
if err != nil { if err != nil {
return domain.StarGift{}, fmt.Errorf("decode star gift document attributes: %w", err) 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) return fmt.Errorf("allocate star gift id: %w", err)
} }
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_catalog (gift_id, active_revision_id, enabled, sort_order) INSERT INTO star_gift_catalog (
VALUES ($1,$2,$3,$4)`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil { 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) return fmt.Errorf("insert star gift catalog: %w", err)
} }
} else { } else {
@ -180,20 +224,38 @@ WHERE gift_id = $1`, giftID).Scan(&revision); err != nil {
INSERT INTO star_gift_catalog_revisions ( INSERT INTO star_gift_catalog_revisions (
id, gift_id, revision, title, stars, convert_stars, document_id, id, gift_id, revision, title, stars, convert_stars, document_id,
animation_json, animation_sha256, source_name, source_format, animation_json, animation_sha256, source_name, source_format,
width, height, frame_rate, in_point, out_point, created_by, command_id 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)`, 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, 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), 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.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 { ); err != nil {
return fmt.Errorf("insert star gift revision: %w", err) return fmt.Errorf("insert star gift revision: %w", err)
} }
if write.GiftID != 0 { if write.GiftID != 0 {
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
UPDATE star_gift_catalog UPDATE star_gift_catalog
SET active_revision_id=$2, enabled=$3, sort_order=$4, updated_at=now() SET active_revision_id=$2, enabled=$3, sort_order=$4, availability_remains=$5,
WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil { 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) 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 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) { func (s *StarGiftStore) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
tag, err := s.db.Exec(ctx, ` tag, err := s.db.Exec(ctx, `
UPDATE star_gift_catalog SET enabled=$2, updated_at=now() 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) { func catalogEntryByID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (domain.StarGiftCatalogEntry, error) {
row := db.QueryRow(ctx, ` row := db.QueryRow(ctx, `
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title, 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), 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.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text, 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) WHERE c.gift_id=$1`, giftID)
var entry domain.StarGiftCatalogEntry var entry domain.StarGiftCatalogEntry
var attrsJSON, thumbsJSON, sourceFormat string var attrsJSON, thumbsJSON, sourceFormat string
var releasedByType string
var releasedByID int64
var hasBackground bool
var background domain.StarGiftBackground
if err := row.Scan( if err := row.Scan(
&entry.Gift.ID, &entry.Gift.RevisionID, &entry.Gift.Stars, &entry.Gift.ConvertStars, &entry.Gift.Title, &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.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.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, &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 { ); err != nil {
return domain.StarGiftCatalogEntry{}, err 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) attrs, err := decodeDocumentAttributes(attrsJSON)
if err != nil { if err != nil {
return domain.StarGiftCatalogEntry{}, err return domain.StarGiftCatalogEntry{}, err
@ -315,14 +464,14 @@ func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) (
WITH next_id AS ( WITH next_id AS (
SELECT nextval(pg_get_serial_sequence('public.peer_star_gifts', 'id'))::bigint AS id 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, 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, 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 FROM next_id
RETURNING id`, RETURNING id`,
string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.RevisionID, gift.MsgID, gift.SavedID, gift.Date, 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 { if err != nil {
return 0, fmt.Errorf("create star gift: %w", err) 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 JOIN star_gift_catalog c ON c.gift_id = p.gift_id
LEFT JOIN star_gift_collectible_revisions acr LEFT JOIN star_gift_collectible_revisions acr
ON acr.id = c.collectible_revision_id AND acr.status = 'published'` 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} args := []any{string(owner.Type), owner.ID}
if filter.ExcludeUnsaved { if filter.ExcludeUnsaved {
conditions = append(conditions, "NOT p.unsaved") 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) limitPlaceholder := len(args)
rows, err := s.db.Query(ctx, ` 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, 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, 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) COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i FROM star_gift_collection_items i
@ -434,47 +585,95 @@ func (s *StarGiftStore) ResolveSavedIDs(ctx context.Context, owner domain.Peer,
if len(refs) == 0 { if len(refs) == 0 {
return []int64{}, nil return []int64{}, nil
} }
type resolveKey struct {
value int64
slug string
}
keys := make([]resolveKey, 0, len(refs))
values := make([]int64, 0, len(refs)) values := make([]int64, 0, len(refs))
seenValues := make(map[int64]struct{}, len(refs)) slugs := make([]string, 0, len(refs))
column := "msg_id" seenKeys := make(map[string]struct{}, len(refs))
for _, ref := range refs { for _, ref := range refs {
if ref.Owner != owner || !ref.Valid() { if ref.Owner != owner || !ref.Valid() {
return nil, domain.ErrStarGiftNotFound 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) value := int64(ref.MsgID)
if owner.Type == domain.PeerTypeChannel { if owner.Type == domain.PeerTypeChannel {
column = "saved_id"
value = ref.SavedID value = ref.SavedID
} }
if _, duplicate := seenValues[value]; duplicate { key := fmt.Sprintf("id:%d", value)
if _, duplicate := seenKeys[key]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid return nil, domain.ErrStarGiftCollectibleInvalid
} }
seenValues[value] = struct{}{} seenKeys[key] = struct{}{}
keys = append(keys, resolveKey{value: value})
values = append(values, value) values = append(values, value)
} }
rows, err := s.db.Query(ctx, `SELECT `+column+`::bigint, id FROM peer_star_gifts query := `SELECT p.saved_id::bigint, COALESCE(u.slug, ''), p.id
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) 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 { if err != nil {
return nil, fmt.Errorf("resolve saved star gifts: %w", err) return nil, fmt.Errorf("resolve saved star gifts: %w", err)
} }
defer rows.Close() 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() { for rows.Next() {
var value, id int64 var primaryValue, id int64
if err := rows.Scan(&value, &id); err != nil { var slug string
if err := rows.Scan(&primaryValue, &slug, &id); err != nil {
return nil, fmt.Errorf("scan resolved saved star gift: %w", err) 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 { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate resolved saved star gifts: %w", err) return nil, fmt.Errorf("iterate resolved saved star gifts: %w", err)
} }
out := make([]int64, 0, len(values)) out := make([]int64, 0, len(keys))
for _, value := range values { seenIDs := make(map[int64]struct{}, len(keys))
id := resolved[value] for _, key := range keys {
id := resolvedValues[key.value]
if key.slug != "" {
id = resolvedSlugs[key.slug]
}
if id == 0 { if id == 0 {
return nil, domain.ErrStarGiftNotFound return nil, domain.ErrStarGiftNotFound
} }
if _, duplicate := seenIDs[id]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seenIDs[id] = struct{}{}
out = append(out, id) out = append(out, id)
} }
return out, nil return out, nil
@ -487,7 +686,9 @@ func (s *StarGiftStore) GetByRef(ctx context.Context, ref domain.SavedStarGiftRe
where, args := savedStarGiftRefWhere(ref) where, args := savedStarGiftRefWhere(ref)
row := s.db.QueryRow(ctx, ` 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, 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, 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) COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i FROM star_gift_collection_items i
@ -510,7 +711,7 @@ func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (in
return 0, nil return 0, nil
} }
var n int 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 0, fmt.Errorf("count star gifts: %w", err)
} }
return n, nil return n, nil
@ -524,7 +725,7 @@ func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGift
args = append(args, unsaved) args = append(args, unsaved)
tag, err := s.db.Exec(ctx, ` tag, err := s.db.Exec(ctx, `
UPDATE peer_star_gifts SET unsaved = $4 UPDATE peer_star_gifts SET unsaved = $4
WHERE `+where+` AND NOT converted`, args...) WHERE `+where+` AND lifecycle_status='active'`, args...)
if err != nil { if err != nil {
return false, fmt.Errorf("set star gift unsaved: %w", err) 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) where, args := savedStarGiftRefWhere(ref)
row := tx.QueryRow(ctx, ` 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, 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, 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) COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i FROM star_gift_collection_items i
@ -564,13 +767,14 @@ WHERE `+where+` FOR UPDATE`, args...)
if g.UniqueGiftID != 0 { if g.UniqueGiftID != 0 {
return domain.ErrStarGiftAlreadyUpgraded 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) return fmt.Errorf("mark star gift converted: %w", err)
} }
if err := removeSavedGiftFromCollections(ctx, tx, g.Owner, g.ID); err != nil { if err := removeSavedGiftFromCollections(ctx, tx, g.Owner, g.ID); err != nil {
return err return err
} }
g.Converted = true g.Converted = true
g.LifecycleStatus = domain.StarGiftLifecycleConverted
g.Unsaved = true g.Unsaved = true
g.PinnedOrder = 0 g.PinnedOrder = 0
g.CollectionIDs = nil g.CollectionIDs = nil
@ -587,7 +791,9 @@ func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) {
var g domain.SavedStarGift var g domain.SavedStarGift
var ownerType string 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, 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 { &g.UpgradeMsgID, &g.PinnedOrder, &g.CollectionIDs); err != nil {
return domain.SavedStarGift{}, err return domain.SavedStarGift{}, err
} }
@ -597,6 +803,10 @@ func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) {
func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) { func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) {
args := []any{string(ref.Owner.Type), ref.Owner.ID} 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 { switch ref.Owner.Type {
case domain.PeerTypeChannel: case domain.PeerTypeChannel:
args = append(args, ref.SavedID) args = append(args, ref.SavedID)

View file

@ -14,6 +14,34 @@ import (
"telesrv/internal/store/postgres/sqlcgen" "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) { func (s *StarGiftStore) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix)) write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
write.Actor = strings.TrimSpace(write.Actor) 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 var revisionID int64
if err := tx.QueryRow(ctx, ` if err := tx.QueryRow(ctx, `
INSERT INTO star_gift_collectible_revisions INSERT INTO star_gift_collectible_revisions
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id) (gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id,
VALUES ($1,$2,$3,$4,$5,'draft',$6,$7) official_gift_id, source_manifest_sha256)
RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID).Scan(&revisionID); err != nil { 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) return fmt.Errorf("insert collectible revision: %w", err)
} }
media := NewMediaStore(tx) 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 { for _, attribute := range attributes {
if err := media.PutDocument(ctx, *attribute.Document); err != nil { if err := media.PutDocument(ctx, *attribute.Document); err != nil {
return fmt.Errorf("put collectible %s document: %w", attribute.Kind, err) 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) return fmt.Errorf("put collectible %s blob: %w", attribute.Kind, err)
} }
animation := attribute.Animation animation := attribute.Animation
query := fmt.Sprintf(` var query string
if models {
query = fmt.Sprintf(`
INSERT INTO %s INSERT INTO %s
(collectible_revision_id, name, document_id, animation_json, animation_sha256, (collectible_revision_id, name, document_id, animation_json, animation_sha256,
source_name, source_format, width, height, frame_rate, in_point, out_point, source_name, source_format, width, height, frame_rate, in_point, out_point,
rarity_permille, sort_order) 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)`, table) 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, if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat), string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint, animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
attribute.RarityPermille, attribute.SortOrder); err != nil { string(attribute.RarityKind), nullablePermille(attribute), attribute.Crafted,
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err) 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 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 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 return err
} }
for _, attribute := range write.Backdrops { for _, attribute := range write.Backdrops {
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_collectible_backdrops INSERT INTO star_gift_collectible_backdrops
(collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color, (collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_permille, sort_order) text_color, rarity_kind, rarity_permille, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID, 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.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) 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 var publishedAt pgtype.Timestamptz
if err := db.QueryRow(ctx, ` if err := db.QueryRow(ctx, `
SELECT id, gift_id, revision, upgrade_stars, supply_total, issued, slug_prefix, status, 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( FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan(
&revision.ID, &revision.GiftID, &revision.Revision, &revision.UpgradeStars, &revision.SupplyTotal, &revision.ID, &revision.GiftID, &revision.Revision, &revision.UpgradeStars, &revision.SupplyTotal,
&revision.Issued, &revision.SlugPrefix, &status, &revision.CreatedBy, &revision.CreatedAt, &publishedAt, &revision.Issued, &revision.SlugPrefix, &status, &revision.CreatedBy, &revision.CreatedAt, &publishedAt,
&revision.OfficialGiftID, &revision.SourceManifestSHA256,
); err != nil { ); err != nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("get collectible revision: %w", err) 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 return nil, domain.ErrStarGiftCollectibleInvalid
} }
rows, err := db.Query(ctx, fmt.Sprintf(` 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.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, 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.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text d.attributes::text, d.thumbs::text
FROM %s a JOIN documents d ON d.id=a.document_id 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 { if err != nil {
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err) 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() { for rows.Next() {
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Document: &domain.Document{}, Animation: &domain.StarGiftAnimation{}} attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Document: &domain.Document{}, Animation: &domain.StarGiftAnimation{}}
var attrsJSON, thumbsJSON, sourceFormat string 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.JSON, &attribute.Animation.SHA256, &attribute.Animation.SourceName, &sourceFormat,
&attribute.Animation.Width, &attribute.Animation.Height, &attribute.Animation.FrameRate, &attribute.Animation.InPoint, &attribute.Animation.OutPoint, &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, &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) { func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) {
rows, err := db.Query(ctx, ` rows, err := db.Query(ctx, `
SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color, 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) FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID)
if err != nil { if err != nil {
return nil, fmt.Errorf("list collectible backdrops: %w", err) 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} attribute := domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop}
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.BackdropID, if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.BackdropID,
&attribute.CenterColor, &attribute.EdgeColor, &attribute.PatternColor, &attribute.TextColor, &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 return nil, err
} }
out = append(out, attribute) out = append(out, attribute)
@ -307,14 +364,24 @@ func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string,
func uniqueStarGiftQuery(predicate string) string { func uniqueStarGiftQuery(predicate string) string {
return fmt.Sprintf(` return fmt.Sprintf(`
SELECT u.id, u.gift_id, u.collectible_revision_id, u.source_saved_gift_id, u.title, u.slug, u.num, 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, COALESCE(u.owner_peer_type,''), COALESCE(u.owner_peer_id,0), u.keep_original_details, u.created_at,
r.issued, r.supply_total, sg.from_user_id, sg.owner_peer_type, sg.owner_peer_id, 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, 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, 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, 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 FROM unique_star_gifts u
JOIN star_gift_collectible_revisions r ON r.id=u.collectible_revision_id 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 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 documents pd ON pd.id=p.document_id
JOIN star_gift_collectible_backdrops b ON b.id=u.backdrop_attribute_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 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) WHERE %s`, predicate)
} }
func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) { func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) {
var unique domain.UniqueStarGift 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.Model.Kind = domain.StarGiftCollectibleModel
unique.Pattern.Kind = domain.StarGiftCollectiblePattern unique.Pattern.Kind = domain.StarGiftCollectiblePattern
unique.Backdrop.Kind = domain.StarGiftCollectibleBackdrop unique.Backdrop.Kind = domain.StarGiftCollectibleBackdrop
@ -337,23 +406,40 @@ func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) {
var modelAttrs, modelThumbs, patternAttrs, patternThumbs string var modelAttrs, modelThumbs, patternAttrs, patternThumbs string
if err := row.Scan(&unique.ID, &unique.GiftID, &unique.CollectibleRevisionID, &unique.SourceSavedGiftID, 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.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.OriginalFromUserID, &originalOwnerType, &unique.OriginalOwner.ID, &unique.OriginalDate,
&unique.OriginalMessage, &unique.OriginalNameHidden, &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.ID, &unique.Model.Document.AccessHash, &unique.Model.Document.FileReference,
&unique.Model.Document.Date, &unique.Model.Document.MimeType, &unique.Model.Document.Size, &unique.Model.Document.Date, &unique.Model.Document.MimeType, &unique.Model.Document.Size,
&unique.Model.Document.DCID, &modelAttrs, &modelThumbs, &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.ID, &unique.Pattern.Document.AccessHash, &unique.Pattern.Document.FileReference,
&unique.Pattern.Document.Date, &unique.Pattern.Document.MimeType, &unique.Pattern.Document.Size, &unique.Pattern.Document.Date, &unique.Pattern.Document.MimeType, &unique.Pattern.Document.Size,
&unique.Pattern.Document.DCID, &patternAttrs, &patternThumbs, &unique.Pattern.Document.DCID, &patternAttrs, &patternThumbs,
&unique.Backdrop.ID, &unique.Backdrop.Name, &unique.Backdrop.BackdropID, &unique.Backdrop.CenterColor, &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) return domain.UniqueStarGift{}, fmt.Errorf("get unique star gift: %w", err)
} }
unique.Owner.Type = domain.PeerType(ownerType) unique.Owner.Type = domain.PeerType(ownerType)
unique.OriginalOwner.Type = domain.PeerType(originalOwnerType) 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.Model.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Pattern.CollectibleRevisionID = unique.CollectibleRevisionID unique.Pattern.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Backdrop.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, ` rows, err := db.Query(ctx, `
SELECT id FROM peer_star_gifts 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) FOR UPDATE`, string(owner.Type), owner.ID, ids)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -34,26 +34,35 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
poolRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ poolRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "comet-" + suffix, GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "comet-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{ 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"), Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("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{{ 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"), Document: collectibleTestDocumentPtr(baseDocumentID+2, "pattern.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+2, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs"),
}}, }},
Backdrops: []domain.StarGiftCollectibleAttribute{{ Backdrops: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1, Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1,
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
RarityPermille: 1000, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 999,
}}, }},
Actor: "integration", CommandID: "collectibles-" + suffix, Actor: "integration", CommandID: "collectibles-" + suffix,
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: make([]byte, 32),
}) })
if err != nil { if err != nil {
t.Fatalf("publish collectible pool: %v", err) 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) t.Fatalf("published pool = %+v", poolRevision)
} }
availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1}) 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) 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, 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 { savedID := saved.ID
t.Fatalf("create saved gift: %v", err)
}
stars := NewStarsStore(pool) stars := NewStarsStore(pool)
if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, 1700001001); err != nil { if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, 1700001001); err != nil {
t.Fatalf("grant upgrade stars: %v", err) t.Fatalf("grant upgrade stars: %v", err)
} }
messages := NewMessageStore(pool)
upgrades := NewStarGiftUpgradeStore(pool, messages) upgrades := NewStarGiftUpgradeStore(pool, messages)
req := domain.StarGiftUpgradeRequest{ 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, KeepOriginalDetails: true, ChargeStars: 100, FormID: 991,
CommandKey: "paid-" + suffix, Date: 1700001002, 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 { ownerMessage.Media.ServiceAction.StarGiftUnique == nil || ownerMessage.Media.ServiceAction.StarGiftUnique.Gift.ID != upgraded.Unique.ID {
t.Fatalf("owner upgrade service message = %+v", ownerMessage) 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 ( var (
issued, uniqueCount, commandCount int issued, uniqueCount, commandCount int
@ -128,12 +160,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
if issued != 1 || uniqueCount != 1 || commandCount != 1 || reason != string(domain.StarsReasonGiftUpgrade) { 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) 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) replayed, err := upgrades.UpgradeStarGift(ctx, req)
if err != nil { if err != nil {
t.Fatalf("replay upgrade: %v", err) 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) t.Fatalf("replayed upgrade = %+v", replayed)
} }
conflictingReplay := req conflictingReplay := req
@ -152,17 +191,15 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
t.Fatalf("balance after retries = %+v err %v", bal, err) 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, 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 // A later pool revision may raise the current price; the historical paid
// amount remains an entitlement instead of being compared to that price. // 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 { prepaidSavedID := prepaidSaved.ID
t.Fatalf("create prepaid saved gift: %v", err)
}
prepaid, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ 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, RequirePrepaid: true, CommandKey: "prepaid-" + suffix, Date: 1700001005,
}) })
if err != nil { if err != nil {
@ -174,26 +211,24 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
t.Fatalf("prepaid upgrade = %+v", prepaid) 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, 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 { insufficientSavedID := insufficientSaved.ID
t.Fatalf("create insufficient saved gift: %v", err)
}
if _, err := stars.Debit(ctx, owner.ID, 850, domain.StarsReasonReaction, if _, err := stars.Debit(ctx, owner.ID, 850, domain.StarsReasonReaction,
domain.Peer{Type: domain.PeerTypeChannel, ID: 777001}, 1700001007, "paid reaction", ""); err != nil { domain.Peer{Type: domain.PeerTypeChannel, ID: 777001}, 1700001007, "paid reaction", ""); err != nil {
t.Fatalf("seed isolated paid reaction debit: %v", err) t.Fatalf("seed isolated paid reaction debit: %v", err)
} }
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003}, UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID},
ChargeStars: 100, CommandKey: "insufficient-" + suffix, Date: 1700001008, ChargeStars: 100, FormID: 994, CommandKey: "insufficient-" + suffix, Date: 1700001008,
}); !errors.Is(err, domain.ErrStarsInsufficient) { }); !errors.Is(err, domain.ErrStarsInsufficient) {
t.Fatalf("insufficient upgrade err = %v", err) t.Fatalf("insufficient upgrade err = %v", err)
} }
insufficientSaved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003}) insufficientAfter, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID})
if err != nil || !found || insufficientSaved.ID != insufficientSavedID || insufficientSaved.UniqueGiftID != 0 { if err != nil || !found || insufficientAfter.ID != insufficientSavedID || insufficientAfter.UniqueGiftID != 0 {
t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientSaved, found, err) 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 { 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) 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", "") concurrentOwner := createTestUser(t, ctx, users, "+1778"+suffix+"43", "ConcurrentOwner", "")
concurrentPeer := domain.Peer{Type: domain.PeerTypeUser, ID: concurrentOwner.ID} 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, Owner: concurrentPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
MsgID: 700004, Date: 1700001010, ConvertStars: 25, Date: 1700001010, ConvertStars: 25,
}); err != nil { })
t.Fatalf("create concurrent upgrade target: %v", err)
}
if _, _, err := stars.EnsureGrant(ctx, concurrentOwner.ID, 150, 1700001011); err != nil { if _, _, err := stars.EnsureGrant(ctx, concurrentOwner.ID, 150, 1700001011); err != nil {
t.Fatalf("grant concurrent balance: %v", err) t.Fatalf("grant concurrent balance: %v", err)
} }
@ -238,7 +271,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
go func() { go func() {
<-start <-start
_, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ _, 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, ChargeStars: 100, FormID: 993, CommandKey: "concurrent-upgrade-" + suffix, Date: 1700001012,
}) })
results <- concurrentDebitResult{kind: "gift_upgrade", err: err} results <- concurrentDebitResult{kind: "gift_upgrade", err: err}
@ -302,19 +335,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
soldOutRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ soldOutRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: soldOutEntry.Gift.ID, UpgradeStars: 10, SupplyTotal: 1, SlugPrefix: "nova-" + suffix, GiftID: soldOutEntry.Gift.ID, UpgradeStars: 10, SupplyTotal: 1, SlugPrefix: "nova-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{ 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"), Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs"),
}}, }},
Patterns: []domain.StarGiftCollectibleAttribute{{ 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"), Document: collectibleTestDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs"),
}}, }},
Backdrops: []domain.StarGiftCollectibleAttribute{{ Backdrops: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2, Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2,
CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff, CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff,
RarityPermille: 1000, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
}}, }},
Actor: "integration", CommandID: "soldout-pool-" + suffix, Actor: "integration", CommandID: "soldout-pool-" + suffix,
}) })
@ -323,27 +356,26 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
} }
soldOutOwner := createTestUser(t, ctx, users, "+1778"+suffix+"44", "SoldOutOwner", "") soldOutOwner := createTestUser(t, ctx, users, "+1778"+suffix+"44", "SoldOutOwner", "")
soldOutPeer := domain.Peer{Type: domain.PeerTypeUser, ID: soldOutOwner.ID} soldOutPeer := domain.Peer{Type: domain.PeerTypeUser, ID: soldOutOwner.ID}
for index, msgID := range []int{700010, 700011} { soldOutSaved := make([]domain.SavedStarGift, 0, 2)
if _, err := gifts.Create(ctx, domain.SavedStarGift{ 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, Owner: soldOutPeer, FromUserID: sender.ID, GiftID: soldOutEntry.Gift.ID, RevisionID: soldOutEntry.Gift.RevisionID,
MsgID: msgID, Date: 1700001020 + index, ConvertStars: 10, Date: 1700001020 + index, ConvertStars: 10,
}); err != nil { }))
t.Fatalf("create sold-out target %d: %v", msgID, err)
}
} }
if _, _, err := stars.EnsureGrant(ctx, soldOutOwner.ID, 100, 1700001022); err != nil { if _, _, err := stars.EnsureGrant(ctx, soldOutOwner.ID, 100, 1700001022); err != nil {
t.Fatalf("grant sold-out owner balance: %v", err) t.Fatalf("grant sold-out owner balance: %v", err)
} }
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700010}, UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[0].MsgID},
ChargeStars: 10, CommandKey: "soldout-first-" + suffix, Date: 1700001023, ChargeStars: 10, FormID: 995, CommandKey: "soldout-first-" + suffix, Date: 1700001023,
}); err != nil { }); err != nil {
t.Fatalf("fill collectible supply: %v", err) t.Fatalf("fill collectible supply: %v", err)
} }
balanceBeforeSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID) balanceBeforeSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID)
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700011}, UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[1].MsgID},
ChargeStars: 10, CommandKey: "soldout-second-" + suffix, Date: 1700001024, ChargeStars: 10, FormID: 996, CommandKey: "soldout-second-" + suffix, Date: 1700001024,
}); !errors.Is(err, domain.ErrStarGiftCollectibleSoldOut) { }); !errors.Is(err, domain.ErrStarGiftCollectibleSoldOut) {
t.Fatalf("sold-out upgrade err = %v", err) t.Fatalf("sold-out upgrade err = %v", err)
} }
@ -357,7 +389,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("create ordinary collection: %v", err) 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 { if err != nil || !converted.Converted || converted.PinnedOrder != 0 || len(converted.CollectionIDs) != 0 {
t.Fatalf("convert collection member = %+v err %v", converted, err) 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 { func collectibleTestAnimation(name string) domain.StarGiftAnimation {
return domain.StarGiftAnimation{ return domain.StarGiftAnimation{
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS, SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,
@ -427,3 +559,53 @@ func collectibleTestBlobPtr(id int64, suffix string) *domain.FileBlob {
blob := collectibleTestBlob(id, suffix) blob := collectibleTestBlob(id, suffix)
return &blob 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}
}

File diff suppressed because it is too large Load diff

View file

@ -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
}

View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -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(&regularLogs); 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(&regularLogs); 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
}

View file

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

View file

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

View file

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

View file

@ -21,18 +21,38 @@ import (
// upgrades. It intentionally shares MessageStore's allocator and transaction // upgrades. It intentionally shares MessageStore's allocator and transaction
// machinery so Stars, issuance, the saved gift and durable updates commit once. // machinery so Stars, issuance, the saved gift and durable updates commit once.
type StarGiftUpgradeStore struct { type StarGiftUpgradeStore struct {
db sqlcgen.DBTX db sqlcgen.DBTX
messages *MessageStore messages *MessageStore
lifecycle domain.StarGiftLifecyclePolicy
} }
func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore) *StarGiftUpgradeStore { type StarGiftUpgradeOption func(*StarGiftUpgradeStore)
return &StarGiftUpgradeStore{db: db, messages: messages}
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) { 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() || 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.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.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.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 return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
} }
saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref) 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", "telesrv:star-gift-upgrade:v1:%s:%d:%d:%t:%d:%t",
commandKey, saved.ID, req.ChargeStars, req.RequirePrepaid, req.FormID, req.KeepOriginalDetails, 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{ placeholder := &domain.MessageMedia{
Kind: domain.MessageMediaKindService, Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{ ServiceAction: &domain.MessageServiceAction{
@ -57,7 +81,7 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
}, },
} }
messageReq := domain.SendPrivateTextRequest{ messageReq := domain.SendPrivateTextRequest{
SenderUserID: saved.FromUserID, SenderUserID: messageSenderID,
RecipientUserID: req.UserID, RecipientUserID: req.UserID,
RandomID: randomID, RandomID: randomID,
Media: placeholder, Media: placeholder,
@ -89,6 +113,19 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
if err != nil { if err != nil {
return err 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 { if revision.Issued >= revision.SupplyTotal {
return domain.ErrStarGiftCollectibleSoldOut return domain.ErrStarGiftCollectibleSoldOut
} }
@ -134,10 +171,12 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
INSERT INTO unique_star_gifts INSERT INTO unique_star_gifts
(id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num, (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, owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id,
backdrop_attribute_id, keep_original_details) backdrop_attribute_id, keep_original_details, original_owner_peer_type, original_owner_peer_id,
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, 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, 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) 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 { 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, ` if _, err := tx.Exec(ctx, `
UPDATE peer_star_gifts UPDATE peer_star_gifts
SET unique_gift_id=$2, prepaid_upgrade_stars=0, convert_stars=0 SET unique_gift_id=$2, prepaid_upgrade_stars=0, prepaid_upgrade_hash='', convert_stars=0,
WHERE id=$1 AND unique_gift_id IS NULL AND NOT converted`, locked.ID, uniqueID); err != nil { 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) return fmt.Errorf("upgrade saved star gift: %w", err)
} }
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_upgrade_commands INSERT INTO star_gift_upgrade_commands
(user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after) (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 { 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) 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.UniqueGiftID = uniqueID
locked.PrepaidUpgradeStars = 0 locked.PrepaidUpgradeStars = 0
locked.ConvertStars = 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 locked.Unique = &unique
result.Saved, result.Unique, result.Balance = locked, unique, balance result.Saved, result.Unique, result.Balance = locked, unique, balance
action := starGiftUpgradeUniqueAction(locked, unique, req, messageSenderID)
messageReq.Media = &domain.MessageMedia{ messageReq.Media = &domain.MessageMedia{
Kind: domain.MessageMediaKindService, Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{ ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique, Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{ StarGiftUnique: action,
Gift: unique, FromUserID: func() int64 {
if locked.NameHidden {
return 0
}
return locked.FromUserID
}(), Peer: locked.Owner, Upgrade: true, Saved: !locked.Unsaved,
PrepaidUpgrade: req.RequirePrepaid,
},
}, },
} }
return nil 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") return fmt.Errorf("save star gift upgrade message id lost aggregate row")
} }
result.Saved.UpgradeMsgID = ownerMessageID 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 return nil
}, },
} }
@ -216,11 +295,186 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq
return result, nil 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) { func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
where, args := savedStarGiftRefWhere(ref) 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, ` 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, 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, 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) COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i 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) { 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 { if err != nil {
return 0, fmt.Errorf("list collectible attributes for issuance: %w", err) 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 { if err := rows.Err(); err != nil {
return 0, err return 0, err
} }
if len(items) == 0 || total != 1000 { if len(items) == 0 || total <= 0 {
return 0, domain.ErrStarGiftCollectibleInvalid return 0, domain.ErrStarGiftCollectibleInvalid
} }
draw, err := rand.Int(rand.Reader, big.NewInt(int64(total))) 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 return domain.StarGiftUpgradeResult{}, err
} }
var commandUniqueID int64 receipt, found, err := s.StarGiftUpgradeReceipt(ctx, req.UserID, req.CommandKey)
var balanceAfter int64 if err != nil {
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 {
return domain.StarGiftUpgradeResult{}, fmt.Errorf("load star gift upgrade replay: %w", err) 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 return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
} }
uniqueCopy := unique uniqueCopy := unique
saved.Unique = &uniqueCopy saved.Unique = &uniqueCopy
sourceEdits, err := s.loadUpgradeSourceReplay(ctx, req, saved, receipt.SourceEditPts)
if err != nil {
return domain.StarGiftUpgradeResult{}, err
}
return domain.StarGiftUpgradeResult{ return domain.StarGiftUpgradeResult{
Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: balanceAfter}, Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: receipt.BalanceAfter},
Send: sent, Duplicate: true, Send: sent, SourceEdits: sourceEdits, Duplicate: true,
}, nil }, 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) var _ store.StarGiftUpgradeStore = (*StarGiftUpgradeStore)(nil)

View file

@ -16,6 +16,9 @@ type StarGiftStore interface {
CatalogRevision(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) CatalogRevision(ctx context.Context, revisionID int64) (domain.StarGift, bool, error)
// CreateCatalogRevision 创建新礼物或为既有礼物创建新版本,并原子切换当前版本。 // CreateCatalogRevision 创建新礼物或为既有礼物创建新版本,并原子切换当前版本。
CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) 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) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error)
SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error)
// AnimationJSON 返回当前版本的规范化 Lottie JSON供管理后台安全预览。 // AnimationJSON 返回当前版本的规范化 Lottie JSON供管理后台安全预览。
@ -60,4 +63,44 @@ type StarGiftStore interface {
// private service-message updates. // private service-message updates.
type StarGiftUpgradeStore interface { type StarGiftUpgradeStore interface {
UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) 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
} }

View file

@ -21,16 +21,18 @@ import (
) )
type Config struct { type Config struct {
Addr string Addr string
PublicBaseURL string PublicBaseURL string
AppScheme string AppScheme string
WebBaseURL string WebBaseURL string
AppName string AppName string
StickerSets StickerSetResolver StickerSets StickerSetResolver
Users UsernameResolver Users UsernameResolver
Channels PublicChannelResolver Channels PublicChannelResolver
Privacy AnonymousPrivacyResolver Privacy AnonymousPrivacyResolver
Photos ProfilePhotoResolver Photos ProfilePhotoResolver
UniqueGifts UniqueStarGiftResolver
GiftWithdrawals StarGiftWithdrawalResolver
} }
type StickerSetResolver interface { type StickerSetResolver interface {
@ -57,6 +59,15 @@ type ProfilePhotoResolver interface {
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) 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) { func Start(ctx context.Context, cfg Config, logger *zap.Logger) (*http.Server, error) {
addr := strings.TrimSpace(cfg.Addr) addr := strings.TrimSpace(cfg.Addr)
if addr == "" { if addr == "" {
@ -132,16 +143,18 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
logger = zap.NewNop() logger = zap.NewNop()
} }
h := &handler{ h := &handler{
stickerSets: cfg.StickerSets, stickerSets: cfg.StickerSets,
users: cfg.Users, users: cfg.Users,
channels: cfg.Channels, channels: cfg.Channels,
privacy: cfg.Privacy, privacy: cfg.Privacy,
photos: cfg.Photos, photos: cfg.Photos,
publicBaseURL: cfg.PublicBaseURL, uniqueGifts: cfg.UniqueGifts,
appScheme: cfg.AppScheme, giftWithdrawals: cfg.GiftWithdrawals,
webBaseURL: cfg.WebBaseURL, publicBaseURL: cfg.PublicBaseURL,
appName: cfg.AppName, appScheme: cfg.AppScheme,
logger: logger, webBaseURL: cfg.WebBaseURL,
appName: cfg.AppName,
logger: logger,
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", h.healthz) 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 /addstickers/{shortName}", h.addStickers)
mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji) mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji)
mux.HandleFunc("GET /addlist/{slug}", h.addList) 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)
mux.HandleFunc("GET /{username}/{$}", h.usernameLink) mux.HandleFunc("GET /{username}/{$}", h.usernameLink)
return publicSecurityHeaders(mux), nil return publicSecurityHeaders(mux), nil
} }
type handler struct { type handler struct {
stickerSets StickerSetResolver stickerSets StickerSetResolver
users UsernameResolver users UsernameResolver
channels PublicChannelResolver channels PublicChannelResolver
privacy AnonymousPrivacyResolver privacy AnonymousPrivacyResolver
photos ProfilePhotoResolver photos ProfilePhotoResolver
publicBaseURL string uniqueGifts UniqueStarGiftResolver
appScheme string giftWithdrawals StarGiftWithdrawalResolver
webBaseURL string publicBaseURL string
appName string appScheme string
logger *zap.Logger 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(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}} · {{.AppName}}</title><style>
body{font:16px/1.5 system-ui,sans-serif;background:#f4f6f8;color:#17212b;margin:0;padding:32px}.card{max-width:560px;margin:8vh auto;background:#fff;border-radius:16px;padding:28px;box-shadow:0 8px 32px #0002}h1{margin-top:0}.meta{overflow-wrap:anywhere;color:#53606d}button{border:0;border-radius:10px;padding:12px 18px;background:#2481cc;color:#fff;font-weight:600;cursor:pointer}.done{color:#18864b;font-weight:600}
</style></head><body><main class="card"><h1>{{.Title}}</h1><p class="meta">Collectible: {{.Slug}}</p>
{{if .CanComplete}}<p>This export is handled only by {{.AppName}}'s internal ledger. No external blockchain or wallet is contacted.</p><form method="post"><button type="submit">Complete local export</button></form><p class="meta">Expires: {{.ExpiresAt}}</p>{{else}}<p class="done">Status: {{.Status}}</p>{{if .OwnerAddress}}<p class="meta">Owner address: {{.OwnerAddress}}</p><p class="meta">Gift address: {{.GiftAddress}}</p>{{end}}{{end}}
</main></body></html>`))
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) { 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) { func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PathValue("username")) username := strings.TrimSpace(r.PathValue("username"))
if !validUsernamePath(username) { if !validUsernamePath(username) {
@ -734,7 +869,7 @@ func publicWebAppURL(webBaseURL, legacyURL string) string {
func publicSecurityHeaders(next http.Handler) http.Handler { func publicSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
@ -764,6 +899,23 @@ func validSlugPath(slug string) bool {
return links.ValidChatlistSlug(slug) 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 { func validUsernamePath(username string) bool {
if username == "" || len(username) < 5 || len(username) > 32 { if username == "" || len(username) < 5 || len(username) > 32 {
return false return false

View file

@ -7,6 +7,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -44,6 +45,158 @@ func newTestHandlerWithPublicPeers(
return h 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: `<script>alert("x")</script>`, 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(), `<script>alert("x")</script>`) {
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) { func TestHandlerServesStickerSetLandingPage(t *testing.T) {
resolver := fakeResolver{ resolver := fakeResolver{
"fresh_pack": { "fresh_pack": {
@ -185,7 +338,10 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) {
"stickers_pack": {ShortName: "stickers_pack", Title: "Stickers", Kind: domain.StickerSetKindStickers}, "stickers_pack": {ShortName: "stickers_pack", Title: "Stickers", Kind: domain.StickerSetKindStickers},
"emoji_pack": {ShortName: "emoji_pack", Title: "Emoji", Kind: domain.StickerSetKindEmoji, Emojis: true}, "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", PublicBaseURL: "https://links.example.test",
AppScheme: "example-chat", AppScheme: "example-chat",
WebBaseURL: "https://web.example.test/client/", 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: "/addstickers/stickers_pack", want: "example-chat://addstickers?set=stickers_pack"},
{path: "/addemoji/emoji_pack", want: "example-chat://addemoji?set=emoji_pack"}, {path: "/addemoji/emoji_pack", want: "example-chat://addemoji?set=emoji_pack"},
{path: "/addlist/shared-folder", want: "example-chat://addlist?slug=shared-folder"}, {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() rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil)) h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil))