package postgres import ( "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/binary" "errors" "fmt" "math/big" "strings" "github.com/jackc/pgx/v5" "telesrv/internal/domain" "telesrv/internal/store" "telesrv/internal/store/postgres/sqlcgen" ) // StarGiftUpgradeStore is the PostgreSQL aggregate coordinator for collectible // upgrades. It intentionally shares MessageStore's allocator and transaction // machinery so Stars, issuance, the saved gift and durable updates commit once. type StarGiftUpgradeStore struct { db sqlcgen.DBTX messages *MessageStore lifecycle domain.StarGiftLifecyclePolicy } type StarGiftUpgradeOption func(*StarGiftUpgradeStore) func WithStarGiftLifecyclePolicy(policy domain.StarGiftLifecyclePolicy) StarGiftUpgradeOption { return func(s *StarGiftUpgradeStore) { if policy.Valid() { s.lifecycle = policy } } } func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore, opts ...StarGiftUpgradeOption) *StarGiftUpgradeStore { s := &StarGiftUpgradeStore{db: db, messages: messages, lifecycle: domain.StarGiftLifecyclePolicy{ TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 250, }} for _, opt := range opts { opt(s) } return s } // GrantUniqueStarGift atomically assigns a newly minted collectible from the // official system account. The saved gift, unique issuance, service message, // pts/outbox and immutable command receipt share MessageStore's transaction. func (s *StarGiftUpgradeStore) GrantUniqueStarGift(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) { req.CommandKey = strings.TrimSpace(req.CommandKey) req.Message = strings.TrimSpace(req.Message) if s == nil || s.db == nil || s.messages == nil || req.SenderID != domain.OfficialSystemUserID || req.Recipient.Type != domain.PeerTypeUser || req.Recipient.ID <= 0 || req.GiftID <= 0 || !req.Upgrade || req.CommandKey == "" || len(req.CommandKey) > 256 || req.Date <= 0 || len([]rune(req.Message)) > 128 || req.ModelAttributeID < 0 || req.PatternAttributeID < 0 || req.BackdropAttributeID < 0 { return domain.AdminStarGiftGrantResult{}, domain.ErrStarGiftCollectibleInvalid } fingerprint := adminStarGiftGrantFingerprint(req) if replay, found, err := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, domain.SendPrivateTextResult{}); err != nil || found { return replay, err } placeholder := &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ Assigned: true, Saved: true, }, }} messageReq := domain.SendPrivateTextRequest{ SenderUserID: req.SenderID, RecipientUserID: req.Recipient.ID, RandomID: lifecycleCommandRandomID("admin-collectible-grant", req.Recipient.ID, req.CommandKey), Date: req.Date, OriginUserID: req.SenderID, RecipientBlocked: req.RecipientBlocked, IdempotencyFingerprint: fingerprint[:], Media: placeholder, } var result domain.AdminStarGiftGrantResult hooks := privateSendTxHooks{ afterAllocate: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest, senderBoxID, recipientBoxID int) error { ownerMessageID := recipientBoxID if req.SenderID == req.Recipient.ID { ownerMessageID = senderBoxID } if ownerMessageID <= 0 { return fmt.Errorf("admin collectible grant missing owner message id") } var revisionID int64 var enabled bool if err := tx.QueryRow(ctx, `SELECT active_revision_id,enabled FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, req.GiftID).Scan(&revisionID, &enabled); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.ErrStarGiftNotFound } return fmt.Errorf("lock admin collectible catalog gift: %w", err) } gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, revisionID) if err != nil { return err } if !found || !enabled || gift.ID != req.GiftID { return domain.ErrStarGiftNotFound } revision, err := lockActiveCollectibleRevision(ctx, tx, gift.ID) if err != nil { return err } if revision.Issued >= revision.SupplyTotal { return domain.ErrStarGiftCollectibleSoldOut } modelID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID, req.ModelAttributeID) if err != nil { return err } patternID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID, req.PatternAttributeID) if err != nil { return err } backdropID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID, req.BackdropAttributeID) if err != nil { return err } var craftable bool if err := tx.QueryRow(ctx, `SELECT EXISTS ( SELECT 1 FROM star_gift_collectible_models WHERE collectible_revision_id=$1 AND crafted )`, revision.ID).Scan(&craftable); err != nil { return fmt.Errorf("load admin collectible craft capability: %w", err) } craftChancePermille, canCraftAt := 0, 0 if craftable { craftChancePermille = s.lifecycle.CraftChancePermille if craftChancePermille > 0 { canCraftAt = starGiftCraftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds) } } saved := domain.SavedStarGift{ Owner: req.Recipient, FromUserID: req.SenderID, GiftID: gift.ID, RevisionID: gift.RevisionID, MsgID: ownerMessageID, Date: req.Date, NameHidden: req.HideName, Unsaved: req.RecipientUnsaved, LifecycleStatus: domain.StarGiftLifecycleActive, Message: req.Message, TransferStars: s.lifecycle.TransferStars, CanExportAt: starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds), CanTransferAt: starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds), CanResellAt: starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds), DropOriginalDetailsStars: s.lifecycle.DropOriginalDetailsStars, CanCraftAt: canCraftAt, UpgradeMsgID: ownerMessageID, } savedID, err := NewStarGiftStore(tx).Create(ctx, saved) if err != nil { return err } saved.ID = savedID num := revision.Issued + 1 var uniqueID int64 if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil { return fmt.Errorf("allocate admin unique star gift id: %w", err) } slug := fmt.Sprintf("%s-%d", revision.SlugPrefix, num) if _, err := tx.Exec(ctx, ` INSERT INTO unique_star_gifts (id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num, owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id, backdrop_attribute_id, keep_original_details, original_owner_peer_type, original_owner_peer_id, craft_chance_permille, offer_min_stars) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,true,$13,$14,$15,$16)`, uniqueID, gift.ID, revision.ID, savedID, gift.Title, slug, num, string(req.Recipient.Type), req.Recipient.ID, modelID, patternID, backdropID, string(req.Recipient.Type), req.Recipient.ID, craftChancePermille, s.lifecycle.OfferMinStars); err != nil { return fmt.Errorf("insert admin 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 { return fmt.Errorf("increment admin collectible issuance: %w", err) } tag, err := tx.Exec(ctx, ` UPDATE peer_star_gifts SET unique_gift_id=$2,upgrade_msg_id=$3,convert_stars=0,prepaid_upgrade_stars=0,prepaid_upgrade_hash='', transfer_stars=$4,can_export_at=$5,can_transfer_at=$6,can_resell_at=$7, drop_original_details_stars=$8,can_craft_at=$9 WHERE id=$1 AND unique_gift_id IS NULL AND lifecycle_status='active'`, savedID, uniqueID, ownerMessageID, s.lifecycle.TransferStars, saved.CanExportAt, saved.CanTransferAt, saved.CanResellAt, s.lifecycle.DropOriginalDetailsStars, canCraftAt) if err != nil { return fmt.Errorf("link admin unique star gift: %w", err) } if tag.RowsAffected() != 1 { return fmt.Errorf("link admin unique star gift lost aggregate row") } if _, err := tx.Exec(ctx, ` INSERT INTO star_gift_admin_grant_commands (recipient_user_id,command_key,request_fingerprint,sender_user_id,gift_id,saved_gift_id,unique_gift_id,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,to_timestamp($8))`, req.Recipient.ID, req.CommandKey, fingerprint[:], req.SenderID, gift.ID, savedID, uniqueID, req.Date); err != nil { return fmt.Errorf("insert admin collectible grant command: %w", err) } unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID) if err != nil { return err } if !found { return fmt.Errorf("new admin unique star gift %d disappeared", uniqueID) } saved.UniqueGiftID = uniqueID saved.Unique = &unique if err := registerUserStarGiftMessageRef(ctx, tx, req.Recipient.ID, ownerMessageID, savedID, uniqueID); err != nil { return err } result.Saved, result.Unique = saved, unique send.Media = adminStarGiftUniqueMedia(saved, unique) return nil }, } sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) if err != nil { if isUniqueViolation(err) { if replay, found, replayErr := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, sent); replayErr != nil || found { return replay, replayErr } } return domain.AdminStarGiftGrantResult{}, err } result.Send, result.Duplicate = sent, sent.Duplicate if sent.Duplicate { replay, found, replayErr := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, sent) if replayErr != nil { return domain.AdminStarGiftGrantResult{}, replayErr } if !found { return domain.AdminStarGiftGrantResult{}, domain.ErrStarGiftCollectibleInvalid } return replay, nil } return result, nil } func adminStarGiftUniqueMedia(saved domain.SavedStarGift, unique domain.UniqueStarGift) *domain.MessageMedia { fromUserID := saved.FromUserID if saved.NameHidden { fromUserID = 0 } return &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ Gift: unique, FromUserID: fromUserID, Assigned: true, Saved: true, CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt, }, }} } func adminStarGiftGrantFingerprint(req domain.AdminStarGiftGrant) [32]byte { return sha256.Sum256([]byte(fmt.Sprintf( "telesrv:admin-star-gift-grant:v1:%d:%s:%d:%d:%t:%q:%d:%d:%d", req.SenderID, req.Recipient.Type, req.Recipient.ID, req.GiftID, req.HideName, req.Message, req.ModelAttributeID, req.PatternAttributeID, req.BackdropAttributeID, ))) } func (s *StarGiftUpgradeStore) loadAdminStarGiftGrantReplay( ctx context.Context, req domain.AdminStarGiftGrant, fingerprint [32]byte, sent domain.SendPrivateTextResult, ) (domain.AdminStarGiftGrantResult, bool, error) { var storedFingerprint []byte var senderID, giftID, savedID, uniqueID int64 err := s.db.QueryRow(ctx, ` SELECT request_fingerprint,sender_user_id,gift_id,saved_gift_id,unique_gift_id FROM star_gift_admin_grant_commands WHERE recipient_user_id=$1 AND command_key=$2`, req.Recipient.ID, req.CommandKey).Scan( &storedFingerprint, &senderID, &giftID, &savedID, &uniqueID) if errors.Is(err, pgx.ErrNoRows) { return domain.AdminStarGiftGrantResult{}, false, nil } if err != nil { return domain.AdminStarGiftGrantResult{}, false, err } if senderID != req.SenderID || giftID != req.GiftID || !bytes.Equal(storedFingerprint, fingerprint[:]) { return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid } saved, found, err := savedStarGiftByID(ctx, s.db, savedID) if err != nil || !found { if err == nil { err = domain.ErrStarGiftCollectibleInvalid } return domain.AdminStarGiftGrantResult{}, false, err } unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) if err != nil || !found { if err == nil { err = domain.ErrStarGiftCollectibleInvalid } return domain.AdminStarGiftGrantResult{}, false, err } if saved.Owner != req.Recipient || saved.FromUserID != req.SenderID || saved.GiftID != req.GiftID || saved.UniqueGiftID != uniqueID || saved.MsgID <= 0 || saved.UpgradeMsgID != saved.MsgID || unique.SourceSavedGiftID != savedID || unique.Owner != req.Recipient { return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid } if sent.SenderMessage.ID == 0 { replay, replayFound, replayErr := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{ SenderUserID: req.SenderID, RecipientUserID: req.Recipient.ID, RandomID: lifecycleCommandRandomID("admin-collectible-grant", req.Recipient.ID, req.CommandKey), IdempotencyFingerprint: fingerprint[:], }) if replayErr != nil { return domain.AdminStarGiftGrantResult{}, false, replayErr } if !replayFound { return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid } sent = replay } uniqueCopy := unique saved.Unique = &uniqueCopy return domain.AdminStarGiftGrantResult{ Saved: saved, Unique: unique, Send: sent, Duplicate: true, }, true, nil } func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) { if s == nil || s.db == nil || s.messages == nil || req.UserID <= 0 || !req.Ref.Valid() || (req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) || (req.Ref.Owner.Type != domain.PeerTypeUser && req.Ref.Owner.Type != domain.PeerTypeChannel) || req.ChargeStars < 0 || (req.RequirePrepaid && (req.ChargeStars != 0 || req.FormID != 0)) || (!req.RequirePrepaid && (req.ChargeStars <= 0 || req.FormID == 0)) || req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 { return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid } saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref) if err != nil { return domain.StarGiftUpgradeResult{}, err } if !found || saved.FromUserID <= 0 { return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftNotFound } commandKey := strings.TrimSpace(req.CommandKey) fingerprint := sha256.Sum256([]byte(fmt.Sprintf( "telesrv:star-gift-upgrade:v1:%s:%d:%d:%t:%d:%t", commandKey, saved.ID, req.ChargeStars, req.RequirePrepaid, req.FormID, req.KeepOriginalDetails, ))) messageSenderID := saved.FromUserID if saved.Owner.Type == domain.PeerTypeChannel { messageSenderID = domain.OfficialSystemUserID } randomID := starGiftUpgradeRandomID(messageSenderID, req.UserID, commandKey) placeholder := &domain.MessageMedia{ Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{Upgrade: true, Saved: true}, }, } messageReq := domain.SendPrivateTextRequest{ SenderUserID: messageSenderID, RecipientUserID: req.UserID, RandomID: randomID, Media: placeholder, Date: req.Date, OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID, IdempotencyFingerprint: fingerprint[:], } var result domain.StarGiftUpgradeResult hooks := privateSendTxHooks{ before: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) error { locked, err := lockSavedStarGiftForUpgrade(ctx, tx, req.Ref) if err != nil { return err } if locked.ID != saved.ID || locked.FromUserID != saved.FromUserID { return domain.ErrStarGiftCollectibleInvalid } if locked.Converted { return domain.ErrStarGiftAlreadyConverted } if locked.UniqueGiftID != 0 { return domain.ErrStarGiftAlreadyUpgraded } revision, err := lockActiveCollectibleRevision(ctx, tx, locked.GiftID) if err != nil { return err } var craftable bool if err := tx.QueryRow(ctx, `SELECT EXISTS ( SELECT 1 FROM star_gift_collectible_models WHERE collectible_revision_id=$1 AND crafted )`, revision.ID).Scan(&craftable); err != nil { return fmt.Errorf("load collectible craft capability: %w", err) } craftChancePermille := 0 canCraftAt := 0 // Keep the durable Craft entitlement attached to the collectible across // user/channel ownership moves. The RPC projection suppresses the // readiness marker for channel owners until channel Craft execution is // implemented, without destroying the official gift property. if craftable { craftChancePermille = s.lifecycle.CraftChancePermille if craftChancePermille > 0 { canCraftAt = starGiftCraftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds) } } if revision.Issued >= revision.SupplyTotal { return domain.ErrStarGiftCollectibleSoldOut } if req.RequirePrepaid { // Prepayment is an entitlement captured at gift purchase time. A // later published revision may change the current price, but must not // retroactively invalidate that already-paid entitlement. if req.ChargeStars != 0 || locked.PrepaidUpgradeStars <= 0 { return domain.ErrStarGiftCollectibleUnavailable } } else if req.ChargeStars != revision.UpgradeStars { return domain.ErrStarGiftCollectibleUnavailable } balance, err := debitStarGiftUpgrade(ctx, tx, req.UserID, req.ChargeStars, locked.Owner, req.Date) if err != nil { return err } modelID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID, req.ModelAttributeID) if err != nil { return err } patternID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID, req.PatternAttributeID) if err != nil { return err } backdropID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID, req.BackdropAttributeID) if err != nil { return err } num := revision.Issued + 1 var uniqueID int64 if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil { return fmt.Errorf("allocate unique star gift id: %w", err) } var title string if err := tx.QueryRow(ctx, `SELECT title FROM star_gift_catalog_revisions WHERE id=$1`, locked.RevisionID).Scan(&title); err != nil { return fmt.Errorf("load upgrade gift title: %w", err) } slug := fmt.Sprintf("%s-%d", revision.SlugPrefix, num) if _, err := tx.Exec(ctx, ` INSERT INTO unique_star_gifts (id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num, owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id, backdrop_attribute_id, keep_original_details, original_owner_peer_type, original_owner_peer_id, craft_chance_permille, offer_min_stars) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, uniqueID, locked.GiftID, revision.ID, locked.ID, title, slug, num, string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails, string(locked.Owner.Type), locked.Owner.ID, craftChancePermille, s.lifecycle.OfferMinStars); err != nil { return fmt.Errorf("insert unique star gift: %w", err) } if _, err := tx.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued+1 WHERE id=$1`, revision.ID); err != nil { return fmt.Errorf("increment collectible issuance: %w", err) } if _, err := tx.Exec(ctx, ` UPDATE peer_star_gifts SET unique_gift_id=$2, prepaid_upgrade_stars=0, prepaid_upgrade_hash='', convert_stars=0, transfer_stars=$3,can_export_at=$4,can_transfer_at=$5,can_resell_at=$6, drop_original_details_stars=$7,can_craft_at=$8 WHERE id=$1 AND unique_gift_id IS NULL AND lifecycle_status='active'`, locked.ID, uniqueID, s.lifecycle.TransferStars, starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds), starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds), starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds), s.lifecycle.DropOriginalDetailsStars, canCraftAt); err != nil { return fmt.Errorf("upgrade saved star gift: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO star_gift_upgrade_commands (user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after, 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) } unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID) if err != nil { return err } if !found { return fmt.Errorf("new unique star gift %d disappeared", uniqueID) } locked.UniqueGiftID = uniqueID locked.PrepaidUpgradeStars = 0 locked.ConvertStars = 0 locked.TransferStars = s.lifecycle.TransferStars locked.CanExportAt = starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds) locked.CanTransferAt = starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds) locked.CanResellAt = starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds) locked.DropOriginalDetailsStars = s.lifecycle.DropOriginalDetailsStars locked.CanCraftAt = canCraftAt locked.Unique = &unique result.Saved, result.Unique, result.Balance = locked, unique, balance action := starGiftUpgradeUniqueAction(locked, unique, req) messageReq.Media = &domain.MessageMedia{ Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: action, }, } return nil }, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { ownerMessageID := sent.RecipientMessage.ID if result.Saved.Owner.Type == domain.PeerTypeUser && saved.FromUserID == req.UserID { ownerMessageID = sent.SenderMessage.ID } if ownerMessageID <= 0 { return fmt.Errorf("upgrade service message missing owner box") } tag, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET upgrade_msg_id=$2 WHERE id=$1 AND unique_gift_id=$3`, result.Saved.ID, ownerMessageID, result.Unique.ID) if err != nil { return fmt.Errorf("save star gift upgrade message id: %w", err) } if tag.RowsAffected() != 1 { return fmt.Errorf("save star gift upgrade message id lost aggregate row") } if result.Saved.Owner.Type == domain.PeerTypeUser { if err := registerUserStarGiftMessageRef(ctx, tx, result.Saved.Owner.ID, ownerMessageID, result.Saved.ID, result.Unique.ID); err != nil { return err } } else if err := registerViewerStarGiftMessageRef(ctx, tx, req.UserID, ownerMessageID, result.Saved.ID, result.Saved.Owner, result.Unique.ID); err != nil { return err } 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) 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 }, } sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) if err != nil { return domain.StarGiftUpgradeResult{}, err } result.Send = sent result.Duplicate = sent.Duplicate if sent.Duplicate { return s.loadUpgradeReplay(ctx, req, saved, sent) } return result, nil } func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.UniqueStarGift, req domain.StarGiftUpgradeRequest) *domain.MessageStarGiftUniqueAction { fromUserID := saved.FromUserID if saved.NameHidden { fromUserID = 0 } if saved.Owner.Type == domain.PeerTypeChannel { // The private envelope is sent by 777000, while action.from_id identifies // the administrator who performed the upgrade. TDesktop uses that // distinction to render "upgraded" instead of an unknown transfer. fromUserID = req.UserID } peer := saved.Owner savedID := saved.SavedID canCraftAt := saved.CanCraftAt if saved.Owner.Type == domain.PeerTypeUser { // peer and saved_id share one TL flag and are defined for channel gifts. // For user gifts both must be absent; official clients use the emitted // service-message id (registered owner-locally by the send transaction). peer = domain.Peer{} savedID = 0 } else { // The current Craft state machine is user-owned only. Android treats a // positive can_craft_at as the channel Craft entry marker, so do not // advertise a write path that the server cannot execute yet. canCraftAt = 0 } return &domain.MessageStarGiftUniqueAction{ Gift: unique, FromUserID: fromUserID, Peer: peer, 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: canCraftAt, } } func userStarGiftSourceMessageIDs(ctx context.Context, db interface { Query(context.Context, string, ...any) (pgx.Rows, error) }, saved domain.SavedStarGift) ([]int, error) { if saved.Owner.Type != domain.PeerTypeUser || saved.Owner.ID <= 0 || saved.ID <= 0 || saved.MsgID <= 0 { return nil, domain.ErrStarGiftCollectibleInvalid } messageIDs := []int{saved.MsgID} rows, err := db.Query(ctx, ` SELECT msg_id FROM star_gift_user_message_refs WHERE owner_user_id=$1 AND saved_gift_id=$2 AND msg_id<>$3 ORDER BY msg_id`, saved.Owner.ID, saved.ID, saved.MsgID) if err != nil { return nil, fmt.Errorf("list star gift source message refs: %w", err) } defer rows.Close() for rows.Next() { var msgID int if err := rows.Scan(&msgID); err != nil { return nil, fmt.Errorf("scan star gift source message ref: %w", err) } if msgID <= 0 { return nil, fmt.Errorf("star gift source message ref has invalid id") } messageIDs = append(messageIDs, msgID) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate star gift source message refs: %w", err) } return messageIDs, nil } // markPrivateStarGiftSourceUpgradedTx rewrites every ordinary gift projection // owned by the source aggregate: the original gift message and each separately // prepaid-upgrade notification. The two visible boxes of every logical private // message are updated together. upgrade_msg_id is box-local and is set only // when that viewer owns a box for the emitted unique-gift message; a third-party // payer sees the prepayment become non-actionable without receiving an invalid // owner-local link. 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 } messageIDs, err := userStarGiftSourceMessageIDs(ctx, tx, saved) if err != nil { return nil, err } q := sqlcgen.New(tx) 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(messageIDs)*2) seenPrivateMessages := make(map[string]struct{}, len(messageIDs)) primaryRewritten := false for _, sourceMessageID := range messageIDs { var peerType string var peerID int64 err := tx.QueryRow(ctx, ` SELECT peer_type,peer_id FROM message_boxes WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted FOR UPDATE`, req.UserID, sourceMessageID).Scan(&peerType, &peerID) if errors.Is(err, pgx.ErrNoRows) { if sourceMessageID == saved.MsgID { return nil, domain.ErrStarGiftCollectibleInvalid } continue } if err != nil { return nil, fmt.Errorf("lock star gift source ref %d: %w", sourceMessageID, err) } if peerType != string(domain.PeerTypeUser) || peerID <= 0 { return nil, fmt.Errorf("star gift source ref %d is not private", sourceMessageID) } target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{ OwnerUserID: req.UserID, BoxID: int32(sourceMessageID), PeerType: peerType, PeerID: peerID, }) if err != nil { return nil, fmt.Errorf("load star gift source ref %d: %w", sourceMessageID, err) } ownerMedia, err := decodeMessageMedia(target.MediaJson) if err != nil { return nil, fmt.Errorf("decode star gift source ref %d: %w", sourceMessageID, err) } ownerAction := privateStarGiftAction(ownerMedia) if ownerAction == nil { // The newly emitted unique action is registered before source edits in // the same transaction and belongs to the same aggregate, but is not a // source projection to rewrite. if privateStarGiftUniqueAction(ownerMedia) != nil { continue } return nil, fmt.Errorf("star gift source ref %d has invalid media", sourceMessageID) } if ownerAction.GiftID != saved.GiftID { return nil, fmt.Errorf("star gift source ref %d points to gift %d", sourceMessageID, ownerAction.GiftID) } if sourceMessageID != saved.MsgID && (!ownerAction.UpgradeSeparate || !ownerAction.PrepaidUpgrade || ownerAction.GiftMsgID != saved.MsgID) { return nil, fmt.Errorf("star gift source ref %d is not a prepaid notification for message %d", sourceMessageID, saved.MsgID) } logicalKey := fmt.Sprintf("%d:%d", target.MessageSenderID, target.PrivateMessageID) if _, duplicate := seenPrivateMessages[logicalKey]; duplicate { continue } seenPrivateMessages[logicalKey] = struct{}{} boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ OwnerUserIds: privateMessageOwnerIDs(req.UserID, peerID), MessageSenderID: target.MessageSenderID, PrivateMessageID: target.PrivateMessageID, }) if err != nil { return nil, fmt.Errorf("list star gift source ref %d boxes: %w", sourceMessageID, err) } if len(boxes) == 0 { return nil, domain.ErrStarGiftCollectibleInvalid } var privateMediaJSON []byte for _, box := range boxes { media, err := decodeMessageMedia(box.MediaJson) if err != nil { return nil, fmt.Errorf("decode star gift source media: %w", err) } action := privateStarGiftAction(media) if action == nil || action.GiftID != saved.GiftID { return nil, fmt.Errorf("star gift source message %d has invalid media", box.BoxID) } upgradeMessageID := upgradeMessageIDs[box.OwnerUserID] if action.UpgradeMsgID != 0 && upgradeMessageID > 0 && action.UpgradeMsgID != upgradeMessageID { return nil, fmt.Errorf("star gift source message %d has conflicting upgrade message %d", box.BoxID, action.UpgradeMsgID) } if upgradeMessageID > 0 { action.UpgradeMsgID = upgradeMessageID } else { if box.OwnerUserID == req.UserID { return nil, fmt.Errorf("upgrade service message missing owner box") } action.UpgradeMsgID = 0 } action.CanUpgrade = false action.PrepaidUpgradeHash = "" 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, err = encodeSharedPrivateStarGiftMedia(media) if err != nil { return nil, err } } 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) } if sourceMessageID == saved.MsgID { primaryRewritten = true } } if !primaryRewritten { return nil, domain.ErrStarGiftCollectibleInvalid } 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 } // starGiftCraftReadyAt differs intentionally from the other lifecycle delay // fields. Official Android clients use a positive can_craft_at both as the // capability marker and as the readiness boundary, so an immediately // craftable gift must carry its upgrade date instead of omitting the field. func starGiftCraftReadyAt(date, delaySeconds int) int { if date <= 0 || delaySeconds < 0 { return 0 } const maxProtocolDate = int(1<<31 - 1) if date >= maxProtocolDate { return maxProtocolDate } if delaySeconds == 0 { return date } if delaySeconds > maxProtocolDate-date { return maxProtocolDate } return date + delaySeconds } func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) { where, args := savedStarGiftRefWhere(ref) return lockSavedStarGiftWhere(ctx, tx, where, args...) } func lockSavedStarGiftByID(ctx context.Context, tx pgx.Tx, savedID int64) (domain.SavedStarGift, error) { return lockSavedStarGiftWhere(ctx, tx, "p.id = $1", savedID) } func lockSavedStarGiftWhere(ctx context.Context, tx pgx.Tx, where string, args ...any) (domain.SavedStarGift, error) { row := tx.QueryRow(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.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 `+where+` FOR UPDATE`, args...) saved, err := scanSavedStarGift(row) if errors.Is(err, pgx.ErrNoRows) { return domain.SavedStarGift{}, domain.ErrStarGiftNotFound } return saved, err } func lockActiveCollectibleRevision(ctx context.Context, tx pgx.Tx, giftID int64) (domain.StarGiftCollectibleRevision, error) { var revision domain.StarGiftCollectibleRevision var status string err := tx.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 FOR UPDATE OF r`, giftID).Scan( &revision.ID, &revision.GiftID, &revision.UpgradeStars, &revision.SupplyTotal, &revision.Issued, &revision.SlugPrefix, &status) if errors.Is(err, pgx.ErrNoRows) { return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable } if err != nil { return domain.StarGiftCollectibleRevision{}, fmt.Errorf("lock active collectible revision: %w", err) } if status != "published" { return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable } return revision, nil } func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64, peer domain.Peer, date int) (domain.StarsBalance, error) { result := domain.StarsBalance{UserID: userID} var balance int64 err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id=$1 FOR UPDATE`, userID).Scan(&balance, &result.Granted) if amount == 0 && errors.Is(err, pgx.ErrNoRows) { return result, nil } if errors.Is(err, pgx.ErrNoRows) || (err == nil && balance < amount) { return domain.StarsBalance{}, domain.ErrStarsInsufficient } if err != nil { return domain.StarsBalance{}, fmt.Errorf("lock stars balance for gift upgrade: %w", err) } if amount == 0 { result.Balance = balance return result, nil } if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance=balance-$2, updated_at=now() WHERE user_id=$1 RETURNING balance`, userID, amount).Scan(&result.Balance); err != nil { return domain.StarsBalance{}, fmt.Errorf("debit star gift upgrade: %w", err) } if err := insertStarsTxn(ctx, tx, userID, -amount, domain.StarsReasonGiftUpgrade, peer, date, "Star gift upgrade", ""); err != nil { return domain.StarsBalance{}, err } return result, nil } // resolveCollectibleAttribute returns explicitID when it names a renderable // attribute belonging to revisionID (admin-pinned choice), otherwise it falls // back to the weighted random draw. Models excluded from the random pool // (crafted) are also rejected for explicit selection to preserve invariants. func resolveCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID, explicitID int64) (int64, error) { if explicitID <= 0 { return chooseCollectibleAttribute(ctx, tx, table, revisionID) } extra := "" if table == "star_gift_collectible_models" { extra = " AND NOT crafted" } var ok bool if err := tx.QueryRow(ctx, fmt.Sprintf(`SELECT EXISTS (SELECT 1 FROM %s WHERE id=$1 AND collectible_revision_id=$2 AND rarity_kind='permille' AND rarity_permille > 0%s)`, table, extra), explicitID, revisionID).Scan(&ok); err != nil { return 0, fmt.Errorf("validate collectible attribute: %w", err) } if !ok { return 0, domain.ErrStarGiftCollectibleInvalid } return explicitID, nil } func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID int64) (int64, error) { extra := "" if table == "star_gift_collectible_models" { extra = " AND NOT crafted" } rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0%s ORDER BY sort_order, id`, table, extra), revisionID) if err != nil { return 0, fmt.Errorf("list collectible attributes for issuance: %w", err) } defer rows.Close() type weightedID struct { id int64 weight int } items := make([]weightedID, 0) total := 0 for rows.Next() { var item weightedID if err := rows.Scan(&item.id, &item.weight); err != nil { return 0, err } items = append(items, item) total += item.weight } if err := rows.Err(); err != nil { return 0, err } if len(items) == 0 || total <= 0 { return 0, domain.ErrStarGiftCollectibleInvalid } draw, err := rand.Int(rand.Reader, big.NewInt(int64(total))) if err != nil { return 0, fmt.Errorf("draw collectible attribute: %w", err) } value := int(draw.Int64()) for _, item := range items { if value < item.weight { return item.id, nil } value -= item.weight } return 0, domain.ErrStarGiftCollectibleInvalid } func starGiftUpgradeRandomID(senderID, ownerID int64, commandKey string) int64 { sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%d:%s", senderID, ownerID, commandKey))) id := int64(binary.LittleEndian.Uint64(sum[:8]) & 0x7fffffffffffffff) if id == 0 { id = 1 } return id } func (s *StarGiftUpgradeStore) loadUpgradeReplay(ctx context.Context, req domain.StarGiftUpgradeRequest, original domain.SavedStarGift, sent domain.SendPrivateTextResult) (domain.StarGiftUpgradeResult, error) { saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref) if err != nil || !found || saved.UniqueGiftID == 0 { if err == nil { err = domain.ErrStarGiftCollectibleInvalid } return domain.StarGiftUpgradeResult{}, err } unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, saved.UniqueGiftID) if err != nil || !found { if err == nil { err = domain.ErrStarGiftCollectibleInvalid } return domain.StarGiftUpgradeResult{}, err } receipt, found, err := s.StarGiftUpgradeReceipt(ctx, req.UserID, req.CommandKey) if err != nil { return domain.StarGiftUpgradeResult{}, fmt.Errorf("load star gift upgrade replay: %w", err) } if !found || receipt.UniqueGiftID != unique.ID || receipt.SourceSavedGiftID != saved.ID || saved.ID != original.ID || receipt.FormID != req.FormID || receipt.ChargeStars != req.ChargeStars || receipt.RequirePrepaid != req.RequirePrepaid || receipt.KeepOriginalDetails != req.KeepOriginalDetails { return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid } uniqueCopy := unique saved.Unique = &uniqueCopy sourceEdits, err := s.loadUpgradeSourceReplay(ctx, req, saved, receipt.SourceEditPts) if err != nil { return domain.StarGiftUpgradeResult{}, err } return domain.StarGiftUpgradeResult{ Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: receipt.BalanceAfter}, Send: sent, SourceEdits: sourceEdits, Duplicate: true, }, nil } func (s *StarGiftUpgradeStore) loadUpgradeSourceReplay(ctx context.Context, req domain.StarGiftUpgradeRequest, saved domain.SavedStarGift, pts int) ([]domain.EditedMessageForUser, error) { if saved.Owner.Type != domain.PeerTypeUser { return nil, nil } if pts <= 0 || saved.MsgID <= 0 { return nil, domain.ErrStarGiftCollectibleInvalid } messageIDs, err := userStarGiftSourceMessageIDs(ctx, s.db, saved) if err != nil { return nil, err } edits := make([]domain.EditedMessageForUser, 0, len(messageIDs)) for _, messageID := range messageIDs { 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 NOT deleted`, req.UserID, messageID).Scan(&privateMessageID, &messageSenderID) if errors.Is(err, pgx.ErrNoRows) { // A later delete event is authoritative; replaying the old edit here // would transiently resurrect that source projection. continue } if err != nil { return nil, fmt.Errorf("load star gift source replay message %d: %w", messageID, 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 %d: %w", messageID, err) } if len(boxes) != 1 || int(boxes[0].BoxID) != messageID { return nil, domain.ErrStarGiftCollectibleInvalid } media, err := decodeMessageMedia(boxes[0].MediaJson) if err != nil { return nil, fmt.Errorf("decode star gift source replay box %d: %w", messageID, err) } action := privateStarGiftAction(media) if action == nil { if privateStarGiftUniqueAction(media) != nil { continue } return nil, fmt.Errorf("star gift source replay box %d has invalid media", messageID) } if action.GiftID != saved.GiftID || action.CanUpgrade || action.UpgradeMsgID != saved.UpgradeMsgID { return nil, domain.ErrStarGiftCollectibleInvalid } var eventPts, eventDate int if messageID == saved.MsgID { eventPts = pts 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, eventPts, messageID).Scan(&eventDate) } else { err = s.db.QueryRow(ctx, ` SELECT pts,date FROM user_update_events WHERE user_id=$1 AND pts>=$2 AND event_type='edit_message' AND message_box_id=$3 ORDER BY pts LIMIT 1`, req.UserID, pts, messageID).Scan(&eventPts, &eventDate) } if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrStarGiftCollectibleInvalid } return nil, fmt.Errorf("load star gift source replay event %d: %w", messageID, err) } msg, err := messageFromVisibleBoxRow(boxes[0]) if err != nil { return nil, err } msg.Pts = eventPts event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage, Pts: eventPts, PtsCount: 1, Date: eventDate, Message: msg} edits = append(edits, domain.EditedMessageForUser{UserID: req.UserID, Message: msg, Event: event}) } return edits, 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)