Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9

This commit is contained in:
onysd 2026-07-24 17:15:53 +03:00
commit b443ff0c73
277 changed files with 30747 additions and 1551 deletions

View file

@ -168,7 +168,7 @@ func scanAdminCommand(row pgx.Row) (domain.AdminCommand, error) {
func (s *AdminStore) GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
SELECT user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
FROM account_restrictions
WHERE user_id = $1`, userID)
r, err := scanAccountFreeze(row)
@ -181,13 +181,80 @@ WHERE user_id = $1`, userID)
return r, true, nil
}
func (s *AdminStore) GetAccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) {
out := make(map[int64]domain.AccountFreeze)
if s == nil || s.db == nil || len(userIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
FROM account_restrictions
WHERE user_id = ANY($1::bigint[]) AND frozen = true`, userIDs)
if err != nil {
return nil, fmt.Errorf("get account freezes: %w", err)
}
defer rows.Close()
for rows.Next() {
freeze, err := scanAccountFreeze(rows)
if err != nil {
return nil, fmt.Errorf("scan account freeze: %w", err)
}
out[freeze.UserID] = freeze
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account freezes: %w", err)
}
return out, nil
}
func (s *AdminStore) SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return setAccountFreezeRow(ctx, s.db, freeze)
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.AccountFreeze{}, fmt.Errorf("begin set account freeze: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
out, err := setAccountFreezeRow(ctx, tx, freeze)
if err != nil {
return domain.AccountFreeze{}, err
}
if err := enqueueAccountFreezeNotifications(ctx, tx, out); err != nil {
return domain.AccountFreeze{}, err
}
// User visibility participates in the same cache/version invalidation spine
// as profile and dialog changes. These functions emit cross-instance NOTIFY
// events only after the surrounding transaction commits.
if _, err := tx.Exec(ctx, `SELECT telesrv_bump_contact_accounts_for_user($1)`, out.UserID); err != nil {
return domain.AccountFreeze{}, fmt.Errorf("bump frozen user contact projections: %w", err)
}
if _, err := tx.Exec(ctx, `SELECT telesrv_bump_private_dialog_light_for_user($1)`, out.UserID); err != nil {
return domain.AccountFreeze{}, fmt.Errorf("bump frozen user dialog projections: %w", err)
}
if _, err := tx.Exec(ctx, `SELECT telesrv_bump_read_model_version('user_visibility', 0, 'user', $1)`, out.UserID); err != nil {
return domain.AccountFreeze{}, fmt.Errorf("bump frozen user visibility: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.AccountFreeze{}, fmt.Errorf("commit set account freeze: %w", err)
}
committed = true
return out, nil
}
func setAccountFreezeRow(ctx context.Context, db sqlcgen.DBTX, freeze domain.AccountFreeze) (domain.AccountFreeze, error) {
var since, until any
if freeze.Frozen {
since = freeze.Since
until = freeze.Until
}
row := s.db.QueryRow(ctx, `
row := db.QueryRow(ctx, `
INSERT INTO account_restrictions (
user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
)
@ -200,8 +267,9 @@ ON CONFLICT (user_id) DO UPDATE SET
reason = EXCLUDED.reason,
actor = EXCLUDED.actor,
command_id = EXCLUDED.command_id,
version = account_restrictions.version + 1,
updated_at = now()
RETURNING user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`,
RETURNING user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`,
freeze.UserID, freeze.Frozen, since, until, freeze.AppealURL, freeze.Reason, freeze.Actor, freeze.CommandID,
)
out, err := scanAccountFreeze(row)
@ -211,12 +279,16 @@ RETURNING user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor
return out, nil
}
func scanAccountFreeze(row pgx.Row) (domain.AccountFreeze, error) {
type accountFreezeScanner interface {
Scan(dest ...any) error
}
func scanAccountFreeze(row accountFreezeScanner) (domain.AccountFreeze, error) {
var r domain.AccountFreeze
var since, until pgtype.Timestamptz
var updated time.Time
if err := row.Scan(
&r.UserID, &r.Frozen, &since, &until, &r.AppealURL,
&r.UserID, &r.Frozen, &r.Version, &since, &until, &r.AppealURL,
&r.Reason, &r.Actor, &r.CommandID, &updated,
); err != nil {
return domain.AccountFreeze{}, err
@ -230,3 +302,84 @@ func scanAccountFreeze(row pgx.Row) (domain.AccountFreeze, error) {
r.UpdatedAt = updated
return r, nil
}
func enqueueAccountFreezeNotifications(ctx context.Context, tx pgx.Tx, freeze domain.AccountFreeze) error {
const maxAccountFreezeNotificationAudience = 4096
_, err := tx.Exec(ctx, `
INSERT INTO account_freeze_notifications (target_user_id, frozen_user_id, version, frozen)
SELECT audience.user_id, $1, $2, $3
FROM (
SELECT user_id
FROM (
SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity
FROM contacts WHERE user_id = $1
UNION ALL
SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1
UNION ALL
SELECT peer_id, 1, top_message_date
FROM dialogs WHERE user_id = $1 AND peer_type = 'user'
UNION ALL
SELECT user_id, 1, top_message_date
FROM dialogs WHERE peer_type = 'user' AND peer_id = $1
) candidates
GROUP BY user_id
ORDER BY min(priority), max(activity) DESC, user_id
LIMIT $4
) audience
JOIN users u ON u.id = audience.user_id
WHERE audience.user_id <> $1 AND u.deleted_at IS NULL
ON CONFLICT (target_user_id, frozen_user_id) DO UPDATE SET
version = EXCLUDED.version,
frozen = EXCLUDED.frozen,
status = 'pending',
attempts = 0,
next_attempt_at = now(),
lease_until = NULL,
last_error = '',
updated_at = now()`, freeze.UserID, freeze.Version, freeze.Frozen, maxAccountFreezeNotificationAudience)
if err != nil {
return fmt.Errorf("enqueue account freeze notifications: %w", err)
}
return nil
}
func (s *AdminStore) ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error) {
if s == nil || s.db == nil || limit <= 0 || lease <= 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, `
WITH claim AS (
SELECT id FROM account_freeze_notifications
WHERE (status = 'pending' AND next_attempt_at <= $1)
OR (status = 'dispatching' AND lease_until <= $1)
ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2
)
UPDATE account_freeze_notifications n
SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1
FROM claim WHERE n.id = claim.id
RETURNING n.id, n.target_user_id, n.frozen_user_id, n.version, n.frozen, n.attempts`, now, limit, now.Add(lease))
if err != nil {
return nil, fmt.Errorf("claim account freeze notifications: %w", err)
}
defer rows.Close()
out := make([]domain.AccountFreezeNotification, 0)
for rows.Next() {
var n domain.AccountFreezeNotification
if err := rows.Scan(&n.ID, &n.TargetUserID, &n.FrozenUserID, &n.Version, &n.Frozen, &n.Attempts); err != nil {
return nil, fmt.Errorf("scan account freeze notification: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}
func (s *AdminStore) CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error {
_, err := s.db.Exec(ctx, `
UPDATE account_freeze_notifications
SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $3
WHERE id = $1 AND version = $2`, id, version, now)
if err != nil {
return fmt.Errorf("complete account freeze notification: %w", err)
}
return nil
}

View file

@ -33,11 +33,12 @@ func TestAccountFreezeMigrationAndStoreRoundTrip(t *testing.T) {
const (
frozenUserID = int64(1999999881)
activeUserID = int64(1999999882)
observerID = int64(1999999883)
)
for _, user := range []struct {
id int64
phone string
}{{frozenUserID, "1999999881"}, {activeUserID, "1999999882"}} {
}{{frozenUserID, "1999999881"}, {activeUserID, "1999999882"}, {observerID, "1999999883"}} {
if _, err := tx.Exec(ctx, `
INSERT INTO users (id, access_hash, phone, first_name)
VALUES ($1, $1, $2, 'Freeze migration test')`, user.id, user.phone); err != nil {
@ -60,10 +61,32 @@ VALUES ($1, true, 'legacy freeze', 'ops', 'legacy-freeze', $2)`, frozenUserID, l
t.Fatalf("GetAccountFreeze migrated = %+v found=%v err=%v", migrated, found, err)
}
if !migrated.Frozen || !migrated.Since.Equal(legacyUpdatedAt) ||
!migrated.Until.Equal(legacyUpdatedAt.Add(7*24*time.Hour)) || migrated.AppealURL != "https://t.me/SpamBot" {
!migrated.Until.Equal(legacyUpdatedAt.Add(7*24*time.Hour)) || migrated.AppealURL != "https://t.me/SpamBot" || migrated.Version != 1 {
t.Fatalf("migrated freeze = %+v", migrated)
}
if _, err := tx.Exec(ctx, `
INSERT INTO contacts (user_id, contact_user_id, contact_first_name)
VALUES ($1, $2, 'Visible frozen peer')`, observerID, activeUserID); err != nil {
t.Fatalf("insert observer contact: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO dialogs (user_id, peer_type, peer_id, top_message_id, top_message_date)
VALUES ($1, 'user', $2, 1, 100)`, observerID, activeUserID); err != nil {
t.Fatalf("insert observer dialog: %v", err)
}
var contactVersionBefore, dialogVersionBefore int64
if err := tx.QueryRow(ctx, `
SELECT version FROM read_model_versions
WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, observerID).Scan(&contactVersionBefore); err != nil {
t.Fatalf("read initial contact projection version: %v", err)
}
if err := tx.QueryRow(ctx, `
SELECT version FROM read_model_versions
WHERE model = 'dialog_light' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2`, observerID, activeUserID).Scan(&dialogVersionBefore); err != nil {
t.Fatalf("read initial dialog projection version: %v", err)
}
since := time.Date(2026, 7, 15, 2, 0, 0, 0, time.UTC)
want := domain.AccountFreeze{
UserID: activeUserID,
@ -75,21 +98,80 @@ VALUES ($1, true, 'legacy freeze', 'ops', 'legacy-freeze', $2)`, frozenUserID, l
Actor: "ops",
CommandID: "freeze-round-trip",
}
if _, err := store.SetAccountFreeze(ctx, want); err != nil {
updated, err := store.SetAccountFreeze(ctx, want)
if err != nil {
t.Fatalf("SetAccountFreeze active: %v", err)
}
if updated.Version != 1 {
t.Fatalf("first freeze version = %d, want 1", updated.Version)
}
got, found, err := store.GetAccountFreeze(ctx, activeUserID)
if err != nil || !found || !got.Frozen || !got.Since.Equal(want.Since) ||
!got.Until.Equal(want.Until) || got.AppealURL != want.AppealURL {
!got.Until.Equal(want.Until) || got.AppealURL != want.AppealURL || got.Version != 1 {
t.Fatalf("active round trip = %+v found=%v err=%v", got, found, err)
}
if _, err := store.SetAccountFreeze(ctx, domain.AccountFreeze{
var contactVersionAfter, dialogVersionAfter, visibilityVersion int64
if err := tx.QueryRow(ctx, `
SELECT version FROM read_model_versions
WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, observerID).Scan(&contactVersionAfter); err != nil {
t.Fatalf("read frozen contact projection version: %v", err)
}
if err := tx.QueryRow(ctx, `
SELECT version FROM read_model_versions
WHERE model = 'dialog_light' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2`, observerID, activeUserID).Scan(&dialogVersionAfter); err != nil {
t.Fatalf("read frozen dialog projection version: %v", err)
}
if contactVersionAfter <= contactVersionBefore || dialogVersionAfter <= dialogVersionBefore {
t.Fatalf("projection versions contact %d->%d dialog %d->%d, want increments",
contactVersionBefore, contactVersionAfter, dialogVersionBefore, dialogVersionAfter)
}
if err := tx.QueryRow(ctx, `
SELECT version FROM read_model_versions
WHERE model = 'user_visibility' AND owner_user_id = 0 AND peer_type = 'user' AND peer_id = $1`, activeUserID).Scan(&visibilityVersion); err != nil || visibilityVersion != 1 {
t.Fatalf("user visibility version = %d err=%v, want 1", visibilityVersion, err)
}
claimAt := time.Now().UTC().Add(time.Minute)
claimed, err := store.ClaimAccountFreezeNotifications(ctx, claimAt, 10, time.Minute)
if err != nil || len(claimed) != 1 {
t.Fatalf("claim frozen notification = %+v err=%v, want one", claimed, err)
}
oldNotification := claimed[0]
if oldNotification.TargetUserID != observerID || oldNotification.FrozenUserID != activeUserID || !oldNotification.Frozen || oldNotification.Version != 1 {
t.Fatalf("frozen notification = %+v", oldNotification)
}
updated, err = store.SetAccountFreeze(ctx, domain.AccountFreeze{
UserID: activeUserID, Reason: "appeal accepted", Actor: "ops", CommandID: "unfreeze-round-trip",
}); err != nil {
})
if err != nil {
t.Fatalf("SetAccountFreeze inactive: %v", err)
}
if updated.Version != 2 {
t.Fatalf("unfreeze version = %d, want 2", updated.Version)
}
// A worker that claimed v1 before the unfreeze cannot acknowledge the
// coalesced v2 row and suppress its online refresh.
if err := store.CompleteAccountFreezeNotification(ctx, oldNotification.ID, oldNotification.Version, claimAt); err != nil {
t.Fatalf("complete stale notification: %v", err)
}
claimed, err = store.ClaimAccountFreezeNotifications(ctx, claimAt.Add(time.Minute), 10, time.Minute)
if err != nil || len(claimed) != 1 {
t.Fatalf("claim unfreeze notification = %+v err=%v, want one", claimed, err)
}
newNotification := claimed[0]
if newNotification.ID != oldNotification.ID || newNotification.Version != 2 || newNotification.Frozen {
t.Fatalf("coalesced unfreeze notification = %+v, previous=%+v", newNotification, oldNotification)
}
if err := store.CompleteAccountFreezeNotification(ctx, newNotification.ID, newNotification.Version, claimAt.Add(2*time.Minute)); err != nil {
t.Fatalf("complete unfreeze notification: %v", err)
}
var notificationStatus string
if err := tx.QueryRow(ctx, `SELECT status FROM account_freeze_notifications WHERE id = $1`, newNotification.ID).Scan(&notificationStatus); err != nil || notificationStatus != "delivered" {
t.Fatalf("notification status = %q err=%v, want delivered", notificationStatus, err)
}
got, found, err = store.GetAccountFreeze(ctx, activeUserID)
if err != nil || !found || got.Frozen || !got.Since.IsZero() || !got.Until.IsZero() || got.AppealURL != "" {
if err != nil || !found || got.Frozen || !got.Since.IsZero() || !got.Until.IsZero() || got.AppealURL != "" || got.Version != 2 {
t.Fatalf("inactive round trip = %+v found=%v err=%v", got, found, err)
}

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
@ -87,6 +88,91 @@ func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profi
return userFromModel(row), profile, nil
}
// DeleteBotAccount permanently removes a user-created bot in one transaction:
// it revokes the bot's sessions, purges its private state, releases its
// username, drops the bots row (which invalidates the token) and tombstones the
// users row. System service bots and non-bot users are rejected. The reused
// helpers are the same vetted primitives that back account deletion, so the
// tombstone satisfies users_deletion_state_check. Returns the tombstoned user
// for change notifications.
func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error) {
if botUserID == 0 || domain.IsSystemUserID(botUserID) {
return domain.User{}, domain.ErrBotNotFound
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.User{}, fmt.Errorf("delete bot account: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.User{}, fmt.Errorf("delete bot account: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := lockUsersForUpdate(ctx, tx, botUserID); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: lock: %w", err)
}
u, found, err := NewUserStore(tx).ByID(ctx, botUserID)
if err != nil {
return domain.User{}, err
}
if !found || !u.Bot || u.Deleted {
return domain.User{}, domain.ErrBotNotFound
}
// Only bots backed by a bots row (created via /newbot or the admin) are
// deletable here; system service bots are already excluded above.
var hasBotRow bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_user_id = $1)`, botUserID).Scan(&hasBotRow); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: probe bots row: %w", err)
}
if !hasBotRow {
return domain.User{}, domain.ErrBotNotFound
}
now := time.Now().UTC()
if err := enqueueAccountDeletionNotifications(ctx, tx, botUserID); err != nil {
return domain.User{}, err
}
if _, err := revokeByUserExceptTx(ctx, tx, botUserID, 0); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: revoke sessions: %w", err)
}
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
return domain.User{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, ""); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: release username: %w", err)
}
// Drop the bots row so the token can no longer authenticate a login.
if _, err := tx.Exec(ctx, `DELETE FROM bots WHERE bot_user_id = $1`, botUserID); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: delete bots row: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE users SET
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
verified = false, support = false, last_seen_at = 0,
premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0,
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
color_set = false, color = 0, color_background_emoji_id = 0,
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0,
deleted_at = $2, deletion_source = 'manual', deletion_reason = 'admin bot deletion',
account_delete_at = NULL, updated_at = $2
WHERE id = $1 AND deleted_at IS NULL`, botUserID, now); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: tombstone: %w", err)
}
u, found, err = NewUserStore(tx).ByID(ctx, botUserID)
if err != nil || !found {
if err == nil {
err = domain.ErrUserNotFound
}
return domain.User{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: commit: %w", err)
}
return u, nil
}
func (s *BotStore) GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
if botUserID == 0 {
return domain.BotProfile{}, false, nil

View file

@ -35,6 +35,16 @@ func TestBotStoreRoundTripPostgres(t *testing.T) {
if bfProfile.TokenSecret != "" || len(bfProfile.Commands) == 0 {
t.Fatalf("BotFather profile = %+v, want empty token with seeded commands", bfProfile)
}
hasDone := false
for _, command := range bfProfile.Commands {
if command.Command == "done" {
hasDone = true
break
}
}
if !hasDone {
t.Fatalf("BotFather commands = %+v, want /done for persistent /setlogin sessions", bfProfile.Commands)
}
// 空 phone 查询不得命中任何行。
if _, found, err := users.ByPhone(ctx, ""); err != nil || found {
t.Fatalf("ByPhone('') found=%v err=%v, want not found", found, err)

View file

@ -496,7 +496,7 @@ func scanChannel(row rowScanner) (domain.Channel, error) {
func channelScanDest(ch *domain.Channel, rights, reactionPolicy *string, wallpaper **string) []any {
return []any{
&ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Verified,
&ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Verified, &ch.Scam, &ch.Fake, &ch.Gigagroup,
&ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.HasLink, &ch.LinkedChatID, &ch.LinkedCommunityID, &ch.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights,
reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until,
wallpaper, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID,

View file

@ -267,7 +267,7 @@ WHERE i.user_id = $1
AND NOT i.deleted
AND i.role IN ('creator','admin')
AND pm.status = 'active'
AND pm.role IN ('creator','admin')
AND (pm.role = 'creator' OR (pm.role = 'admin' AND COALESCE((pm.admin_rights->>'ManageDirectMessages')::boolean, false)))
ORDER BY COALESCE(d.pinned, false) DESC,
COALESCE(d.pinned_order, 0) DESC,
COALESCE(top_msg.message_date, d.top_message_date, c.date) DESC,

View file

@ -310,7 +310,8 @@ WITH visible_channels AS (
WHERE mono.monoforum AND NOT mono.deleted
AND (EXISTS (
SELECT 1 FROM channel_members admin
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin')
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active'
AND (admin.role = 'creator' OR (admin.role = 'admin' AND COALESCE((admin.admin_rights->>'ManageDirectMessages')::boolean, false)))
) OR EXISTS (
SELECT 1 FROM channel_messages message
WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted
@ -355,7 +356,8 @@ WITH visible_channels AS (
WHERE mono.monoforum AND NOT mono.deleted
AND (EXISTS (
SELECT 1 FROM channel_members admin
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin')
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active'
AND (admin.role = 'creator' OR (admin.role = 'admin' AND COALESCE((admin.admin_rights->>'ManageDirectMessages')::boolean, false)))
) OR EXISTS (
SELECT 1 FROM channel_messages message
WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted

View file

@ -454,7 +454,7 @@ func (s *ChannelStore) monoforumAdminPreview(ctx context.Context, db sqlcgen.DBT
}
return domain.ChannelMember{}, domain.Channel{}, false, err
}
if !isChannelAdmin(parentMember) {
if !parentMember.CanManageDirectMessages() {
return domain.ChannelMember{}, domain.Channel{}, false, nil
}
return syntheticMonoforumAdminMember(mono, parentMember), parent, true, nil

View file

@ -25,7 +25,7 @@ func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int6
base := "channel_id = $1 AND NOT deleted"
extraChannels := []domain.Channel(nil)
if channel.Monoforum {
if isChannelAdmin(member) {
if member.CanManageDirectMessages() {
base += " AND saved_peer_id = 0"
} else {
baseArgs = append(baseArgs, viewerUserID)

View file

@ -99,7 +99,7 @@ FOR SHARE OF m, p`, channel.ID).Scan(
if parentMemberErr != nil && !errors.Is(parentMemberErr, domain.ErrChannelPrivate) {
return domain.SendChannelMessageResult{}, parentMemberErr
}
isAdmin := parentMemberErr == nil && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember)
isAdmin := parentMemberErr == nil && parentMember.CanManageDirectMessages()
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
}
@ -237,7 +237,12 @@ SELECT EXISTS (
return domain.SendChannelMessageResult{}, fmt.Errorf("update monoforum top: %w", err)
}
recipients := []int64{req.SavedPeer.ID}
rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' AND role IN ('creator', 'admin') ORDER BY user_id`, parent.ID)
rows, err := tx.Query(ctx, `
SELECT user_id
FROM channel_members
WHERE channel_id = $1 AND status = 'active'
AND (role = 'creator' OR (role = 'admin' AND COALESCE((admin_rights->>'ManageDirectMessages')::boolean, false)))
ORDER BY user_id`, parent.ID)
if err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("list monoforum recipients: %w", err)
}
@ -313,7 +318,7 @@ func (s *ChannelStore) ListMonoforumHistory(ctx context.Context, filter domain.M
}
// ResolveMonoforumSend 按 id 取 monoforum 频道(不要求调用者是 monoforum 成员),并返回调用者是否为
// 其母广播频道的创建者/管理员。非 monoforum/不存在 → ErrChannelInvalid。
// 其母广播频道 Direct Messages 管理者。非 monoforum/不存在 → ErrChannelInvalid。
func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) {
if viewerUserID == 0 || monoforumID == 0 {
return domain.Channel{}, false, domain.ErrChannelInvalid
@ -330,8 +335,7 @@ func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, m
}
isAdmin := false
if _, member, memberErr := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); memberErr == nil {
isAdmin = member.Status == domain.ChannelMemberActive &&
(member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin)
isAdmin = member.CanManageDirectMessages()
} else if !errors.Is(memberErr, domain.ErrChannelPrivate) {
return domain.Channel{}, false, memberErr
}

View file

@ -284,6 +284,180 @@ func (s *ChannelStore) SetChannelVerified(ctx context.Context, channelID int64,
return channel, nil
}
// SetChannelScamFake 设置/取消频道的 scam 与 fake 标记。
func (s *ChannelStore) SetChannelScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if scam && fake {
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
}
channel, err := s.channelByID(ctx, s.db, channelID)
if err != nil {
return domain.Channel{}, err
}
if channel.Scam == scam && channel.Fake == fake {
return channel, nil
}
if _, err := s.db.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil {
return domain.Channel{}, fmt.Errorf("set channel scam/fake: %w", err)
}
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
channel.Scam = scam
channel.Fake = fake
return channel, nil
}
// SetChannelAdminSettings applies an admin-direct moderation-settings patch
// (no membership/permission checks). nil fields are left unchanged.
func (s *ChannelStore) SetChannelAdminSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if patch.Empty() {
return s.channelByID(ctx, s.db, channelID)
}
sets := make([]string, 0, 7)
args := []any{channelID}
idx := 2
add := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, val)
idx++
}
if patch.Gigagroup != nil {
add("gigagroup", *patch.Gigagroup)
}
if patch.AntiSpam != nil {
add("antispam", *patch.AntiSpam)
}
if patch.ParticipantsHidden != nil {
add("participants_hidden", *patch.ParticipantsHidden)
}
if patch.NoForwards != nil {
add("noforwards", *patch.NoForwards)
}
if patch.JoinToSend != nil {
add("join_to_send", *patch.JoinToSend)
}
if patch.JoinRequest != nil {
add("join_request", *patch.JoinRequest)
}
if patch.SlowmodeSeconds != nil {
add("slowmode_seconds", *patch.SlowmodeSeconds)
}
query := "UPDATE channels SET " + strings.Join(sets, ", ") + ", updated_at = now() WHERE id = $1 AND NOT deleted"
if _, err := s.db.Exec(ctx, query, args...); err != nil {
return domain.Channel{}, fmt.Errorf("set channel admin settings: %w", err)
}
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
return s.channelByID(ctx, s.db, channelID)
}
// SetChannelUsernameAdmin force-sets or clears (empty) a channel username with
// no permission checks. Username uniqueness is still enforced by peer_usernames.
func (s *ChannelStore) SetChannelUsernameAdmin(ctx context.Context, channelID int64, username string) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.Channel{}, fmt.Errorf("set channel username: db does not support transactions")
}
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
usernameLower := strings.ToLower(username)
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.Channel{}, fmt.Errorf("begin set channel username: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
channel, err := s.channelByID(ctx, tx, channelID)
if err != nil {
return domain.Channel{}, err
}
if strings.EqualFold(channel.Username, username) {
return channel, nil
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, usernameLower); err != nil {
return domain.Channel{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, channelID, username); err != nil {
return domain.Channel{}, fmt.Errorf("set channel username: %w", err)
}
if err := markUserChannelMemberIndexPublicTx(ctx, tx, channelID, username != ""); err != nil {
return domain.Channel{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Channel{}, fmt.Errorf("commit set channel username: %w", err)
}
committed = true
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
channel.Username = username
return channel, nil
}
// SetChannelColorAdmin force-sets a channel name/profile color (no permission checks).
func (s *ChannelStore) SetChannelColorAdmin(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
channel, err := s.channelByID(ctx, s.db, channelID)
if err != nil {
return domain.Channel{}, err
}
if forProfile {
if _, err := s.db.Exec(ctx, `UPDATE channels SET profile_color_set = $2, profile_color = $3, profile_color_background_emoji_id = $4, updated_at = now() WHERE id = $1`,
channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil {
return domain.Channel{}, fmt.Errorf("set channel profile color: %w", err)
}
channel.ProfileColor = color
} else {
if _, err := s.db.Exec(ctx, `UPDATE channels SET color_set = $2, color = $3, color_background_emoji_id = $4, updated_at = now() WHERE id = $1`,
channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil {
return domain.Channel{}, fmt.Errorf("set channel color: %w", err)
}
channel.Color = color
}
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
return channel, nil
}
// SetChannelEmojiStatusAdmin force-sets or clears a channel emoji status (no permission checks).
func (s *ChannelStore) SetChannelEmojiStatusAdmin(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if status.DocumentID == 0 {
status.Until = 0
}
channel, err := s.channelByID(ctx, s.db, channelID)
if err != nil {
return domain.Channel{}, err
}
if _, err := s.db.Exec(ctx, `UPDATE channels SET emoji_status_document_id = $2, emoji_status_until = $3, updated_at = now() WHERE id = $1`,
channelID, status.DocumentID, status.Until); err != nil {
return domain.Channel{}, fmt.Errorf("set channel emoji status: %w", err)
}
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
channel.EmojiStatus = status
return channel, nil
}
func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
_ = viewerUserID // zero is the anonymous public-web view; this query is viewer-independent.
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))

View file

@ -113,7 +113,7 @@ func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore
return s
}
const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.verified,
const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.verified, c.scam, c.fake, c.gigagroup,
c.broadcast, c.megagroup, c.forum, c.forum_tabs, c.autotranslation, c.restricted_sponsored, c.broadcast_messages_allowed, c.send_paid_messages_stars, c.noforwards, c.join_to_send, c.join_request, c.signatures, c.pre_history_hidden, c.participants_hidden, c.antispam,
EXISTS (SELECT 1 FROM channel_invites ci WHERE ci.channel_id = c.id AND NOT ci.revoked) AS has_link,
c.linked_chat_id, c.linked_community_id, c.monoforum, c.linked_monoforum_id, c.slowmode_seconds, c.boosts_unrestrict, c.default_banned_rights::text,

View file

@ -0,0 +1,708 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
const suggestedPostSettlementAge = 24 * 60 * 60
type persistedSuggestedPostApproval struct {
monoforumID, parentID, actorID, payerID int64
messageID, scheduleDate, approvalServiceID, publishedMessageID, settlementDue, finalServiceID int
state domain.SuggestedPostLifecycleState
price *domain.SuggestedPostPrice
}
func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error) {
if req.UserID == 0 || req.MonoforumID == 0 || req.MessageID <= 0 || (!req.Reject && strings.TrimSpace(req.RejectComment) != "") {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
if req.Date == 0 {
req.Date = nowUnix()
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("toggle suggested post: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("begin toggle suggested post: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
mono, err := getChannelByID(ctx, tx, req.MonoforumID)
if err != nil {
if errors.Is(err, domain.ErrChannelInvalid) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
return domain.ToggleSuggestedPostApprovalResult{}, err
}
if mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
parent, err := getChannelByID(ctx, tx, mono.LinkedMonoforumID)
if err != nil {
if errors.Is(err, domain.ErrChannelInvalid) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
return domain.ToggleSuggestedPostApprovalResult{}, err
}
if parent.Deleted || !parent.Broadcast || parent.LinkedMonoforumID != mono.ID {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
if _, err := tx.Exec(ctx, `SELECT 1 FROM channel_messages WHERE channel_id=$1 AND id=$2 FOR UPDATE`, mono.ID, req.MessageID); err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
original, err := s.getChannelMessage(ctx, tx, mono.ID, req.MessageID)
if err != nil {
if errors.Is(err, domain.ErrMessageIDInvalid) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
return domain.ToggleSuggestedPostApprovalResult{}, err
}
if original.Deleted || original.SavedPeer.Type != domain.PeerTypeUser || original.SavedPeer.ID == 0 || original.SuggestedPost == nil {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
fromSubscriber := original.From.Type == domain.PeerTypeUser
manager := domain.ChannelMember{}
if fromSubscriber {
manager, err = s.getChannelMember(ctx, tx, parent.ID, req.UserID)
if err != nil {
if errors.Is(err, domain.ErrChannelPrivate) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
}
return domain.ToggleSuggestedPostApprovalResult{}, err
}
if !manager.CanManageDirectMessages() || (!req.Reject && !manager.CanPostChannelMessages()) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
}
} else if req.UserID != original.SavedPeer.ID {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
}
existing, found, err := loadSuggestedPostApprovalTx(ctx, tx, mono.ID, original.ID, true)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
if found && existing.state != domain.SuggestedPostStateBalanceLow {
result, err := s.loadSuggestedPostResultTx(ctx, tx, existing, true)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
committed = true
return result, nil
}
if original.SuggestedPost.Accepted || original.SuggestedPost.Rejected {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostAlreadyHandled
}
price := cloneSuggestedPricePG(original.SuggestedPost.Price)
if price != nil && price.Kind == domain.SuggestedPostPriceStars && price.Nanos != 0 {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
scheduleDate := original.SuggestedPost.ScheduleDate
if req.ScheduleDate > 0 {
scheduleDate = req.ScheduleDate
}
if !req.Reject && scheduleDate > 0 && (scheduleDate < req.Date+5*60 || scheduleDate > req.Date+31*24*60*60) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
recipients, err := monoforumManagerRecipientsTx(ctx, tx, parent.ID, original.SavedPeer.ID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: mono, Parent: parent, SavedPeer: original.SavedPeer, Recipients: recipients}
if req.Reject {
original.SuggestedPost.Accepted, original.SuggestedPost.Rejected = false, true
original, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
result.OriginalMessage = original
result.ServiceMessage, result.ServiceEvent, err = s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, req.UserID, fromSubscriber && manager.CanManageDirectMessages(), original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostRejected: true,
SuggestedPostRejectComment: strings.TrimSpace(req.RejectComment), SuggestedPostPrice: price,
})
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
result.State = domain.SuggestedPostStateRejected
if err := upsertSuggestedPostApprovalTx(ctx, tx, persistedSuggestedPostApproval{monoforumID: mono.ID, messageID: original.ID, parentID: parent.ID, actorID: req.UserID, payerID: original.SavedPeer.ID, state: result.State, price: price, scheduleDate: scheduleDate, approvalServiceID: result.ServiceMessage.ID}, req.Date); err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
} else {
stars, ton, enough, err := reserveSuggestedPostPaymentTx(ctx, tx, original.SavedPeer.ID, parent.ID, price, req.Date)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
if !enough {
if found {
result, err = s.loadSuggestedPostResultTx(ctx, tx, existing, true)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
} else {
result.ServiceMessage, result.ServiceEvent, err = s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, req.UserID, fromSubscriber && manager.CanManageDirectMessages(), original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostBalanceTooLow: true,
SuggestedPostScheduleDate: scheduleDate, SuggestedPostPrice: price,
})
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
result.State = domain.SuggestedPostStateBalanceLow
if err := upsertSuggestedPostApprovalTx(ctx, tx, persistedSuggestedPostApproval{monoforumID: mono.ID, messageID: original.ID, parentID: parent.ID, actorID: req.UserID, payerID: original.SavedPeer.ID, state: result.State, price: price, scheduleDate: scheduleDate, approvalServiceID: result.ServiceMessage.ID}, req.Date); err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
}
} else {
effectivePublishDate := scheduleDate
if effectivePublishDate == 0 {
// TDesktop's "Publish Now" request has no schedule_date flag, but
// its approval service renderer always expects an absolute date.
effectivePublishDate = req.Date
}
original.SuggestedPost.Accepted, original.SuggestedPost.Rejected, original.SuggestedPost.ScheduleDate = true, false, effectivePublishDate
original, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
result.OriginalMessage = original
result.ServiceMessage, result.ServiceEvent, err = s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, req.UserID, fromSubscriber && manager.CanManageDirectMessages(), original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostScheduleDate: effectivePublishDate, SuggestedPostPrice: price})
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
record := persistedSuggestedPostApproval{monoforumID: mono.ID, messageID: original.ID, parentID: parent.ID, actorID: req.UserID, payerID: original.SavedPeer.ID, state: domain.SuggestedPostStateScheduled, price: price, scheduleDate: effectivePublishDate, approvalServiceID: result.ServiceMessage.ID}
if effectivePublishDate <= req.Date {
published, publishErr := s.publishSuggestedPostTx(ctx, tx, parent, original, req.UserID, req.Date)
if publishErr != nil {
return domain.ToggleSuggestedPostApprovalResult{}, publishErr
}
result.Published = &published
record.publishedMessageID = published.Message.ID
if price == nil {
record.state = domain.SuggestedPostStateCompleted
} else {
record.state = domain.SuggestedPostStatePublished
record.settlementDue = req.Date + suggestedPostSettlementAge
}
}
result.State = record.state
if err := upsertSuggestedPostApprovalTx(ctx, tx, record, req.Date); err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
}
}
if err := tx.Commit(ctx); err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("commit toggle suggested post: %w", err)
}
committed = true
result.Monoforum, err = getChannelByID(ctx, s.db, mono.ID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("reload suggested post monoforum after commit: %w", err)
}
result.Parent, err = getChannelByID(ctx, s.db, parent.ID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("reload suggested post parent after commit: %w", err)
}
return result, nil
}
func (s *ChannelStore) persistSuggestedPostEditTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMessage, actor int64, date int) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) {
pts, err := s.reserveChannelPts(ctx, tx, msg.ChannelID)
if err != nil {
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
}
encoded, err := marshalJSON(msg.SuggestedPost, "{}")
if err != nil {
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
}
msg.Pts = pts
if _, err := tx.Exec(ctx, `UPDATE channel_messages SET suggested_post=$3, pts=$4, updated_at=now() WHERE channel_id=$1 AND id=$2 AND NOT deleted`, msg.ChannelID, msg.ID, encoded, pts); err != nil {
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("update suggested post message: %w", err)
}
event := domain.ChannelUpdateEvent{ChannelID: msg.ChannelID, Type: domain.ChannelUpdateEditMessage, Pts: pts, PtsCount: 1, Date: date, Message: msg, SenderUserID: actor}
if err := insertChannelEventTx(ctx, tx, event); err != nil {
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
}
return msg, event, nil
}
func (s *ChannelStore) insertSuggestedPostServiceTx(ctx context.Context, tx pgx.Tx, mono, parent domain.Channel, actor int64, fromChannel bool, saved domain.Peer, replyID, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) {
msgID, err := s.msgIDs.NextChannelMessageID(ctx, mono.ID)
if err != nil {
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
}
pts, err := s.reserveChannelPts(ctx, tx, mono.ID)
if err != nil {
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
}
from := domain.Peer{Type: domain.PeerTypeUser, ID: actor}
if fromChannel {
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
}
msg := domain.ChannelMessage{ChannelID: mono.ID, ID: msgID, SenderUserID: actor, From: from, SavedPeer: saved, Date: date, ReplyTo: &domain.MessageReply{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: mono.ID}, MessageID: replyID}, Action: &action, Pts: pts}
event := domain.ChannelUpdateEvent{ChannelID: mono.ID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: date, Message: msg, SenderUserID: actor}
if err := insertChannelMessageTx(ctx, tx, msg); err != nil {
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
}
if err := insertChannelEventTx(ctx, tx, event); err != nil {
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id=$2, pts=$3, updated_at=now() WHERE id=$1`, mono.ID, msgID, pts); err != nil {
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
}
return msg, event, nil
}
func (s *ChannelStore) publishSuggestedPostTx(ctx context.Context, tx pgx.Tx, parent domain.Channel, original domain.ChannelMessage, actor int64, date int) (domain.SendChannelMessageResult, error) {
msgID, err := s.msgIDs.NextChannelMessageID(ctx, parent.ID)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
pts, err := s.reserveChannelPts(ctx, tx, parent.ID)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
msg := original
msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID = parent.ID, msgID, 0, actor
msg.From, msg.SendAs, msg.SavedPeer, msg.Date, msg.EditDate, msg.Post = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, nil, domain.Peer{}, date, 0, true
msg.ReplyTo, msg.PaidMessageStars, msg.Pts, msg.Deleted = nil, 0, pts, false
event := domain.ChannelUpdateEvent{ChannelID: parent.ID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: date, Message: msg, SenderUserID: actor}
if err := insertChannelMessageTx(ctx, tx, msg); err != nil {
return domain.SendChannelMessageResult{}, err
}
if err := insertChannelEventTx(ctx, tx, event); err != nil {
return domain.SendChannelMessageResult{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id=$2, pts=$3, updated_at=now() WHERE id=$1`, parent.ID, msgID, pts); err != nil {
return domain.SendChannelMessageResult{}, err
}
recipients, err := s.listActiveChannelMemberIDs(ctx, tx, parent.ID, 0)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
parent.TopMessageID, parent.Pts = msgID, pts
return domain.SendChannelMessageResult{Channel: parent, Message: msg, Event: event, Recipients: recipients}, nil
}
func reserveSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) (*domain.StarsBalance, *int64, bool, error) {
if price == nil {
return nil, nil, true, nil
}
switch price.Kind {
case domain.SuggestedPostPriceStars:
if price.Nanos != 0 || price.Amount <= 0 {
return nil, nil, false, domain.ErrSuggestedPostInvalid
}
balance := domain.StarsBalance{UserID: payerID}
err := tx.QueryRow(ctx, `SELECT balance,granted FROM stars_balances WHERE user_id=$1 FOR UPDATE`, payerID).Scan(&balance.Balance, &balance.Granted)
if errors.Is(err, pgx.ErrNoRows) {
return &balance, nil, false, nil
}
if err != nil {
return nil, nil, false, err
}
if balance.Balance < price.Amount {
return &balance, nil, false, nil
}
if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance=balance-$2,updated_at=now() WHERE user_id=$1 RETURNING balance`, payerID, price.Amount).Scan(&balance.Balance); err != nil {
return nil, nil, false, err
}
if err := insertStarsTxn(ctx, tx, payerID, -price.Amount, domain.StarsReasonSuggestedPost, domain.Peer{Type: domain.PeerTypeChannel, ID: parentID}, date, "Suggested post escrow", ""); err != nil {
return nil, nil, false, err
}
return &balance, nil, true, nil
case domain.SuggestedPostPriceTON:
var balance int64
err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id=$1 FOR UPDATE`, payerID).Scan(&balance)
if errors.Is(err, pgx.ErrNoRows) {
return nil, &balance, false, nil
}
if err != nil {
return nil, nil, false, err
}
if balance < price.Amount {
return nil, &balance, false, nil
}
if err := tx.QueryRow(ctx, `UPDATE ton_balances SET balance_nanoton=balance_nanoton-$2,updated_at=now() WHERE user_id=$1 RETURNING balance_nanoton`, payerID, price.Amount).Scan(&balance); err != nil {
return nil, nil, false, err
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,'channel',$4,$5)`, payerID, -price.Amount, string(domain.StarsReasonSuggestedPost), parentID, date); err != nil {
return nil, nil, false, err
}
return nil, &balance, true, nil
default:
return nil, nil, false, domain.ErrSuggestedPostInvalid
}
}
func monoforumManagerRecipientsTx(ctx context.Context, tx pgx.Tx, parentID, subscriberID int64) ([]int64, error) {
rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id=$1 AND status='active' AND (role='creator' OR (role='admin' AND COALESCE((admin_rights->>'ManageDirectMessages')::boolean,false))) ORDER BY user_id`, parentID)
if err != nil {
return nil, err
}
defer rows.Close()
ids := []int64{subscriberID}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return uniqueChannelUserIDs(ids, 0), rows.Err()
}
func upsertSuggestedPostApprovalTx(ctx context.Context, tx pgx.Tx, row persistedSuggestedPostApproval, date int) error {
kind, amount, nanos := "", int64(0), 0
if row.price != nil {
kind, amount, nanos = string(row.price.Kind), row.price.Amount, row.price.Nanos
}
_, err := tx.Exec(ctx, `INSERT INTO suggested_post_approvals(monoforum_id,suggestion_message_id,parent_channel_id,actor_user_id,payer_user_id,state,price_kind,price_amount,price_nanos,schedule_date,approval_service_message_id,published_message_id,settlement_due,final_service_message_id,created_at,updated_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$15)
ON CONFLICT(monoforum_id,suggestion_message_id) DO UPDATE SET actor_user_id=EXCLUDED.actor_user_id,state=EXCLUDED.state,price_kind=EXCLUDED.price_kind,price_amount=EXCLUDED.price_amount,price_nanos=EXCLUDED.price_nanos,schedule_date=EXCLUDED.schedule_date,approval_service_message_id=EXCLUDED.approval_service_message_id,published_message_id=EXCLUDED.published_message_id,settlement_due=EXCLUDED.settlement_due,final_service_message_id=EXCLUDED.final_service_message_id,updated_at=EXCLUDED.updated_at`,
row.monoforumID, row.messageID, row.parentID, row.actorID, row.payerID, string(row.state), kind, amount, nanos, row.scheduleDate, row.approvalServiceID, row.publishedMessageID, row.settlementDue, row.finalServiceID, date)
return err
}
func loadSuggestedPostApprovalTx(ctx context.Context, tx pgx.Tx, monoID int64, messageID int, lock bool) (persistedSuggestedPostApproval, bool, error) {
q := `SELECT parent_channel_id,actor_user_id,payer_user_id,state,price_kind,price_amount,price_nanos,schedule_date,approval_service_message_id,published_message_id,settlement_due,final_service_message_id FROM suggested_post_approvals WHERE monoforum_id=$1 AND suggestion_message_id=$2`
if lock {
q += ` FOR UPDATE`
}
var row persistedSuggestedPostApproval
row.monoforumID, row.messageID = monoID, messageID
var state, kind string
var amount int64
var nanos int
err := tx.QueryRow(ctx, q, monoID, messageID).Scan(&row.parentID, &row.actorID, &row.payerID, &state, &kind, &amount, &nanos, &row.scheduleDate, &row.approvalServiceID, &row.publishedMessageID, &row.settlementDue, &row.finalServiceID)
if errors.Is(err, pgx.ErrNoRows) {
return row, false, nil
}
if err != nil {
return row, false, err
}
row.state = domain.SuggestedPostLifecycleState(state)
if kind != "" {
row.price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceKind(kind), Amount: amount, Nanos: nanos}
}
return row, true, nil
}
func (s *ChannelStore) loadSuggestedPostResultTx(ctx context.Context, tx pgx.Tx, row persistedSuggestedPostApproval, duplicate bool) (domain.ToggleSuggestedPostApprovalResult, error) {
mono, err := getChannelByID(ctx, tx, row.monoforumID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
parent, err := getChannelByID(ctx, tx, row.parentID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
original, err := s.getChannelMessage(ctx, tx, row.monoforumID, row.messageID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
recipients, err := monoforumManagerRecipientsTx(ctx, tx, row.parentID, row.payerID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: mono, Parent: parent, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: row.payerID}, State: row.state, OriginalMessage: original, Recipients: recipients, Duplicate: duplicate}
if original.SuggestedPost != nil && (original.SuggestedPost.Accepted || original.SuggestedPost.Rejected) && original.Pts > 0 {
eventDate, senderUserID := original.Date, row.actorID
// The event row is the exact durable replay source. Retention may have
// pruned an old event, in which case the message snapshot still provides
// a safe replay with its original date and lifecycle actor.
if err := tx.QueryRow(ctx, `SELECT date,sender_user_id FROM channel_update_events WHERE channel_id=$1 AND pts=$2 AND event_type='edit_channel_message'`, row.monoforumID, original.Pts).Scan(&eventDate, &senderUserID); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("load suggested post edit event: %w", err)
}
result.OriginalEvent = domain.ChannelUpdateEvent{ChannelID: row.monoforumID, Type: domain.ChannelUpdateEditMessage, Pts: original.Pts, PtsCount: 1, Date: eventDate, Message: original, SenderUserID: senderUserID}
}
if row.approvalServiceID > 0 {
result.ServiceMessage, err = s.getChannelMessage(ctx, tx, row.monoforumID, row.approvalServiceID)
if err != nil {
return result, err
}
result.ServiceEvent = domain.ChannelUpdateEvent{ChannelID: row.monoforumID, Type: domain.ChannelUpdateNewMessage, Pts: result.ServiceMessage.Pts, PtsCount: 1, Date: result.ServiceMessage.Date, Message: result.ServiceMessage, SenderUserID: row.actorID}
}
if row.publishedMessageID > 0 {
msg, e := s.getChannelMessage(ctx, tx, row.parentID, row.publishedMessageID)
if e != nil {
return result, e
}
event := domain.ChannelUpdateEvent{ChannelID: row.parentID, Type: domain.ChannelUpdateNewMessage, Pts: msg.Pts, PtsCount: 1, Date: msg.Date, Message: msg, SenderUserID: row.actorID}
result.Published = &domain.SendChannelMessageResult{Channel: parent, Message: msg, Event: event}
}
return result, nil
}
func cloneSuggestedPricePG(in *domain.SuggestedPostPrice) *domain.SuggestedPostPrice {
if in == nil {
return nil
}
out := *in
return &out
}
func (s *ChannelStore) ProcessSuggestedPostLifecycle(ctx context.Context, req domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error) {
if req.Now == 0 {
req.Now = nowUnix()
}
if req.Limit <= 0 || req.Limit > 100 {
req.Limit = 100
}
rows, err := s.db.Query(ctx, `
SELECT monoforum_id,suggestion_message_id
FROM suggested_post_approvals a
WHERE (a.state='scheduled' AND a.schedule_date <= $1)
OR (a.state='scheduled' AND EXISTS (
SELECT 1 FROM channel_messages sm
WHERE sm.channel_id=a.monoforum_id AND sm.id=a.suggestion_message_id AND sm.deleted))
OR (a.state='published' AND (a.settlement_due <= $1 OR EXISTS (
SELECT 1 FROM channel_messages m
WHERE m.channel_id=a.parent_channel_id AND m.id=a.published_message_id AND m.deleted)))
ORDER BY CASE WHEN a.state='scheduled' THEN a.schedule_date ELSE a.settlement_due END,
a.monoforum_id,a.suggestion_message_id
LIMIT $2`, req.Now, req.Limit)
if err != nil {
return nil, fmt.Errorf("list due suggested posts: %w", err)
}
type key struct {
mono int64
message int
}
keys := make([]key, 0, req.Limit)
for rows.Next() {
var k key
if err := rows.Scan(&k.mono, &k.message); err != nil {
rows.Close()
return nil, err
}
keys = append(keys, k)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
out := make([]domain.ToggleSuggestedPostApprovalResult, 0, len(keys))
for _, k := range keys {
result, changed, err := s.processSuggestedPostLifecycleOne(ctx, k.mono, k.message, req.Now)
if err != nil {
return out, err
}
if changed {
out = append(out, result)
}
}
return out, nil
}
func (s *ChannelStore) processSuggestedPostLifecycleOne(ctx context.Context, monoID int64, messageID, now int) (domain.ToggleSuggestedPostApprovalResult, bool, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ToggleSuggestedPostApprovalResult{}, false, fmt.Errorf("suggested post lifecycle: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, false, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
row, found, err := loadSuggestedPostApprovalTx(ctx, tx, monoID, messageID, true)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, false, err
}
if !found {
return domain.ToggleSuggestedPostApprovalResult{}, false, fmt.Errorf("suggested post lifecycle invariant: approval row disappeared for monoforum %d message %d", monoID, messageID)
}
if row.state != domain.SuggestedPostStateScheduled && row.state != domain.SuggestedPostStatePublished {
if err := tx.Commit(ctx); err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, false, err
}
committed = true
return domain.ToggleSuggestedPostApprovalResult{}, false, nil
}
mono, err := getChannelByID(ctx, tx, row.monoforumID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, false, err
}
parent, err := getChannelByID(ctx, tx, row.parentID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, false, err
}
original, err := s.getChannelMessage(ctx, tx, row.monoforumID, row.messageID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, false, err
}
recipients, err := monoforumManagerRecipientsTx(ctx, tx, parent.ID, row.payerID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, false, err
}
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: mono, Parent: parent, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: row.payerID}, State: row.state, Recipients: recipients}
changed := false
if row.state == domain.SuggestedPostStateScheduled && original.Deleted {
if row.price != nil {
stars, ton, err := refundSuggestedPostPaymentTx(ctx, tx, row.payerID, row.parentID, row.price, now)
if err != nil {
return result, false, err
}
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
if err != nil {
return result, false, err
}
result.ServiceMessage, result.ServiceEvent, row.finalServiceID = service, event, service.ID
}
row.state, result.State, changed = domain.SuggestedPostStateRefunded, domain.SuggestedPostStateRefunded, true
}
if row.state == domain.SuggestedPostStateScheduled && row.scheduleDate <= now {
published, err := s.publishSuggestedPostTx(ctx, tx, parent, original, row.actorID, now)
if err != nil {
return result, false, err
}
result.Published = &published
row.publishedMessageID = published.Message.ID
if row.price == nil {
row.state = domain.SuggestedPostStateCompleted
} else {
row.state = domain.SuggestedPostStatePublished
row.settlementDue = now + suggestedPostSettlementAge
}
result.State = row.state
changed = true
}
if row.state == domain.SuggestedPostStatePublished {
var deleted bool
var deleteDate int
if err := tx.QueryRow(ctx, `SELECT deleted,delete_date FROM channel_messages WHERE channel_id=$1 AND id=$2 FOR SHARE`, row.parentID, row.publishedMessageID).Scan(&deleted, &deleteDate); err != nil {
return result, false, err
}
if deleted && (deleteDate == 0 || deleteDate < row.settlementDue) {
stars, ton, err := refundSuggestedPostPaymentTx(ctx, tx, row.payerID, row.parentID, row.price, now)
if err != nil {
return result, false, err
}
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
if err != nil {
return result, false, err
}
result.ServiceMessage, result.ServiceEvent = service, event
row.state, row.finalServiceID, result.State = domain.SuggestedPostStateRefunded, service.ID, domain.SuggestedPostStateRefunded
changed = true
} else if row.settlementDue <= now {
if err := settleSuggestedPostPaymentTx(ctx, tx, row.actorID, row.payerID, row.parentID, row.price, now); err != nil {
return result, false, err
}
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostSuccess, SuggestedPostPrice: cloneSuggestedPricePG(row.price)})
if err != nil {
return result, false, err
}
result.ServiceMessage, result.ServiceEvent = service, event
row.state, row.finalServiceID, result.State = domain.SuggestedPostStateCompleted, service.ID, domain.SuggestedPostStateCompleted
changed = true
}
}
if !changed {
if err := tx.Commit(ctx); err != nil {
return result, false, err
}
committed = true
return result, false, nil
}
if err := upsertSuggestedPostApprovalTx(ctx, tx, row, now); err != nil {
return result, false, err
}
if err := tx.Commit(ctx); err != nil {
return result, false, err
}
committed = true
result.Monoforum, err = getChannelByID(ctx, s.db, row.monoforumID)
if err != nil {
return result, true, fmt.Errorf("reload lifecycle monoforum after commit: %w", err)
}
result.Parent, err = getChannelByID(ctx, s.db, row.parentID)
if err != nil {
return result, true, fmt.Errorf("reload lifecycle parent after commit: %w", err)
}
return result, true, nil
}
func (r persistedSuggestedPostApproval) savedPeer() domain.Peer {
return domain.Peer{Type: domain.PeerTypeUser, ID: r.payerID}
}
func refundSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) (*domain.StarsBalance, *int64, error) {
if price == nil {
return nil, nil, nil
}
switch price.Kind {
case domain.SuggestedPostPriceStars:
balance := domain.StarsBalance{UserID: payerID, Granted: true}
if err := tx.QueryRow(ctx, `INSERT INTO stars_balances(user_id,balance,granted) VALUES($1,$2,true) ON CONFLICT(user_id) DO UPDATE SET balance=stars_balances.balance+EXCLUDED.balance,updated_at=now() RETURNING balance,granted`, payerID, price.Amount).Scan(&balance.Balance, &balance.Granted); err != nil {
return nil, nil, err
}
if err := insertStarsTxn(ctx, tx, payerID, price.Amount, domain.StarsReasonSuggestedPost, domain.Peer{Type: domain.PeerTypeChannel, ID: parentID}, date, "Suggested post refund", ""); err != nil {
return nil, nil, err
}
return &balance, nil, nil
case domain.SuggestedPostPriceTON:
var balance int64
if err := tx.QueryRow(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,$2,true) ON CONFLICT(user_id) DO UPDATE SET balance_nanoton=ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now() RETURNING balance_nanoton`, payerID, price.Amount).Scan(&balance); err != nil {
return nil, nil, err
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,'channel',$4,$5)`, payerID, price.Amount, string(domain.StarsReasonSuggestedPost), parentID, date); err != nil {
return nil, nil, err
}
return nil, &balance, nil
default:
return nil, nil, domain.ErrSuggestedPostInvalid
}
}
func settleSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, actorID, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) error {
if price == nil {
return nil
}
credit := price.Amount * paidMessageChannelCommissionPermille / 1000
if credit <= 0 {
return nil
}
switch price.Kind {
case domain.SuggestedPostPriceStars:
if _, err := tx.Exec(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,$2) ON CONFLICT(channel_id) DO UPDATE SET balance=channel_stars_balances.balance+EXCLUDED.balance,updated_at=now()`, parentID, credit); err != nil {
return err
}
_, err := tx.Exec(ctx, `INSERT INTO channel_stars_transactions(channel_id,actor_user_id,amount,reason,peer_type,peer_id,date) VALUES($1,$2,$3,$4,'user',$5,$6)`, parentID, actorID, credit, string(domain.StarsReasonSuggestedPost), payerID, date)
return err
case domain.SuggestedPostPriceTON:
if _, err := tx.Exec(ctx, `INSERT INTO channel_ton_balances(channel_id,balance_nanoton) VALUES($1,$2) ON CONFLICT(channel_id) DO UPDATE SET balance_nanoton=channel_ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now()`, parentID, credit); err != nil {
return err
}
_, err := tx.Exec(ctx, `INSERT INTO channel_ton_transactions(channel_id,actor_user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,$4,'user',$5,$6)`, parentID, actorID, credit, string(domain.StarsReasonSuggestedPost), payerID, date)
return err
default:
return domain.ErrSuggestedPostInvalid
}
}

View file

@ -0,0 +1,135 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
// TestSuggestedPostLifecyclePostgres verifies that message state, channel pts,
// escrow and refund are committed through the real PostgreSQL transaction.
// It is gated by TELESRV_TEST_POSTGRES_DSN and testPool migrates through 0134.
func TestSuggestedPostLifecyclePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 201, Phone: "+1888" + suffix + "01", FirstName: "SuggestOwner"})
if err != nil {
t.Fatal(err)
}
subscriber, err := users.Create(ctx, domain.User{AccessHash: 202, Phone: "+1888" + suffix + "02", FirstName: "SuggestSubscriber"})
if err != nil {
t.Fatal(err)
}
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Suggested " + suffix, Broadcast: true, Date: 1_700_000_000})
if err != nil {
t.Fatal(err)
}
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
if err != nil {
t.Fatal(err)
}
monoID := enabled.Channel.LinkedMonoforumID
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM suggested_post_approvals WHERE monoforum_id=$1`, monoID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID)
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id=ANY($1::bigint[])`, []int64{monoID, created.Channel.ID})
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1::bigint[])`, []int64{owner.ID, subscriber.ID})
})
if _, err := pool.Exec(ctx, `INSERT INTO stars_balances(user_id,balance,granted) VALUES($1,100,true) ON CONFLICT(user_id) DO UPDATE SET balance=100,granted=true`, subscriber.ID); err != nil {
t.Fatal(err)
}
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
suggestion, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 71, Message: "postgres suggestion", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_100})
if err != nil {
t.Fatal(err)
}
approved, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: owner.ID, MonoforumID: monoID, MessageID: suggestion.Message.ID, Date: 1_700_000_200})
if err != nil {
t.Fatal(err)
}
if approved.State != domain.SuggestedPostStatePublished || approved.Published == nil || approved.PayerStarsBalance == nil || approved.PayerStarsBalance.Balance != 90 {
t.Fatalf("approved=%+v", approved)
}
if approved.OriginalMessage.SuggestedPost.ScheduleDate != 1_700_000_200 || approved.ServiceMessage.Action.SuggestedPostScheduleDate != 1_700_000_200 {
t.Fatalf("immediate approval dates original/action=%d/%d, want commit date", approved.OriginalMessage.SuggestedPost.ScheduleDate, approved.ServiceMessage.Action.SuggestedPostScheduleDate)
}
history, err := channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: saved, Limit: 10})
if err != nil {
t.Fatal(err)
}
var persistedApprovalDate int
for _, message := range history.Messages {
if message.ID == approved.ServiceMessage.ID && message.Action != nil {
persistedApprovalDate = message.Action.SuggestedPostScheduleDate
break
}
}
if persistedApprovalDate != 1_700_000_200 {
t.Fatalf("persisted approval history date=%d, want commit date", persistedApprovalDate)
}
replay, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: owner.ID, MonoforumID: monoID, MessageID: suggestion.Message.ID, Date: 1_700_000_201})
if err != nil || !replay.Duplicate || replay.OriginalEvent.Type != domain.ChannelUpdateEditMessage || replay.ServiceEvent.Type != domain.ChannelUpdateNewMessage || replay.Published == nil {
t.Fatalf("approval replay=%+v err=%v", replay, err)
}
var state string
var scheduleDate int
var debit, channelBalance int64
if err := pool.QueryRow(ctx, `SELECT state,schedule_date FROM suggested_post_approvals WHERE monoforum_id=$1 AND suggestion_message_id=$2`, monoID, suggestion.Message.ID).Scan(&state, &scheduleDate); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
t.Fatal(err)
}
_ = pool.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, created.Channel.ID).Scan(&channelBalance)
if state != string(domain.SuggestedPostStatePublished) || scheduleDate != 1_700_000_200 || debit != 90 || channelBalance != 0 {
t.Fatalf("state/schedule/debit/channel=%s/%d/%d/%d", state, scheduleDate, debit, channelBalance)
}
if _, err := pool.Exec(ctx, `UPDATE channel_messages SET deleted=true WHERE channel_id=$1 AND id=$2`, created.Channel.ID, approved.Published.Message.ID); err != nil {
t.Fatal(err)
}
resolved, err := channels.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_300, Limit: 10})
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateRefunded || resolved[0].ServiceMessage.Action == nil || resolved[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostRefund {
t.Fatalf("refund=%+v err=%v", resolved, err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
t.Fatal(err)
}
var txnNet int64
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount),0) FROM stars_transactions WHERE user_id=$1 AND reason=$2`, subscriber.ID, string(domain.StarsReasonSuggestedPost)).Scan(&txnNet); err != nil {
t.Fatal(err)
}
if debit != 100 || txnNet != 0 {
t.Fatalf("refund balance/net=%d/%d, want 100/0", debit, txnNet)
}
late, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 72, Message: "late deletion", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_400})
if err != nil {
t.Fatal(err)
}
approvedAt := 1_700_000_500
lateApproved, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: owner.ID, MonoforumID: monoID, MessageID: late.Message.ID, Date: approvedAt})
if err != nil || lateApproved.Published == nil {
t.Fatalf("late approval=%+v err=%v", lateApproved, err)
}
due := approvedAt + suggestedPostSettlementAge
if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{UserID: owner.ID, ChannelID: created.Channel.ID, IDs: []int{lateApproved.Published.Message.ID}, Date: due + 1}); err != nil {
t.Fatal(err)
}
resolved, err = channels.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: due + 2, Limit: 10})
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateCompleted || resolved[0].ServiceMessage.Action == nil || resolved[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostSuccess {
t.Fatalf("late settlement=%+v err=%v", resolved, err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, created.Channel.ID).Scan(&channelBalance); err != nil {
t.Fatal(err)
}
if debit != 90 || channelBalance != 8 {
t.Fatalf("late settlement balance/channel=%d/%d, want 90/8", debit, channelBalance)
}
}

View file

@ -48,7 +48,7 @@ func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.Cha
args = append(args, member.AvailableMinID)
where += fmt.Sprintf(" AND id > $%d", len(args))
}
if channel.Monoforum && !isChannelAdmin(member) {
if channel.Monoforum && !member.CanManageDirectMessages() {
args = append(args, req.UserID)
where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(args))
}
@ -147,7 +147,7 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
}
rows.Close()
var visibleMonoforumMessageIDs map[int]struct{}
if channel.Monoforum && !isChannelAdmin(member) {
if channel.Monoforum && !member.CanManageDirectMessages() {
messageIDs := make([]int, 0)
for _, row := range eventRows {
messageIDs = append(messageIDs, row.event.MessageIDs...)
@ -172,7 +172,7 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
continue
}
event = visibleEvent
if channel.Monoforum && !isChannelAdmin(member) {
if channel.Monoforum && !member.CanManageDirectMessages() {
event, ok = filterMonoforumEventForUser(event, req.UserID, visibleMonoforumMessageIDs)
if !ok {
continue

View file

@ -117,8 +117,24 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
}
type privateSendTxHooks struct {
before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error
after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error
before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error
projectMedia func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) (privateSendMediaProjection, error)
// afterAllocate runs after the immutable logical message and both box IDs
// exist, but before either box, update event or replay snapshot is written.
// It may finalize req.Media using those IDs; all of its writes remain in the
// same private-send transaction.
afterAllocate func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest, int, int) error
after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error
}
// privateSendMediaProjection separates the logical private-message payload
// from the two account-local message-box projections. Most messages use the
// same media for all three fields. Service actions that carry message ids must
// project those ids per account because box ids are not shared by both users.
type privateSendMediaProjection struct {
Shared *domain.MessageMedia
Sender *domain.MessageMedia
Recipient *domain.MessageMedia
}
func (s *MessageStore) sendPrivateTextWithHooks(ctx context.Context, req domain.SendPrivateTextRequest, hooks privateSendTxHooks) (res domain.SendPrivateTextResult, err error) {
@ -228,7 +244,22 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
return domain.SendPrivateTextResult{}, err
}
}
mediaJSON, err := encodeMessageMedia(req.Media)
media := privateSendMediaProjection{Shared: req.Media, Sender: req.Media, Recipient: req.Media}
if hooks.projectMedia != nil {
media, err = hooks.projectMedia(ctx, tx, &req)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
}
sharedMediaJSON, err := encodeMessageMedia(media.Shared)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
senderMediaJSON, err := encodeMessageMedia(media.Sender)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
recipientMediaJSON, err := encodeMessageMedia(media.Recipient)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
@ -255,7 +286,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
TtlPeriod: int32(ttlPeriod),
ExpiresAt: int32(expiresAt),
EntitiesJson: entities,
MediaJson: mediaJSON,
MediaJson: sharedMediaJSON,
ReplyMarkupJson: replyMarkupJSON,
RichMessageJson: richMessageJSON,
ViaBotID: req.ViaBotID,
@ -299,6 +330,44 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient pts: %w", err)
}
}
if hooks.afterAllocate != nil {
// A callback may replace the media after the ordinary request
// fingerprint was computed. Requiring a complete caller-owned
// fingerprint keeps random_id replay bound to the final aggregate intent.
if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "after-allocate private send"); err != nil {
return domain.SendPrivateTextResult{}, err
}
if err := hooks.afterAllocate(ctx, tx, &req, senderBoxID, recipientBoxID); err != nil {
return domain.SendPrivateTextResult{}, err
}
media = privateSendMediaProjection{Shared: req.Media, Sender: req.Media, Recipient: req.Media}
if hooks.projectMedia != nil {
media, err = hooks.projectMedia(ctx, tx, &req)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
}
sharedMediaJSON, err = encodeMessageMedia(media.Shared)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
senderMediaJSON, err = encodeMessageMedia(media.Sender)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
recipientMediaJSON, err = encodeMessageMedia(media.Recipient)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
tag, err := tx.Exec(ctx, `UPDATE private_messages SET media=$3
WHERE sender_user_id=$1 AND id=$2`, req.SenderUserID, pm.ID, sharedMediaJSON)
if err != nil {
return domain.SendPrivateTextResult{}, fmt.Errorf("finalize private message media: %w", err)
}
if tag.RowsAffected() != 1 {
return domain.SendPrivateTextResult{}, fmt.Errorf("finalize private message media: logical message disappeared")
}
}
senderArg := sqlcgen.CreateMessageBoxParams{
OwnerUserID: req.SenderUserID,
@ -315,7 +384,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
ExpiresAt: int32(expiresAt),
EntitiesJson: entities,
Pts: int32(senderPts),
MediaJson: mediaJSON,
MediaJson: senderMediaJSON,
ReplyMarkupJson: replyMarkupJSON,
RichMessageJson: richMessageJSON,
ViaBotID: req.ViaBotID,
@ -323,7 +392,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
Effect: req.Effect,
// voice/round 在发送者自己的副本上也保持"未听",直到对端
// readMessageContents 触发 sender 侧清除;发给自己无人可听,恒已读。
MediaUnread: req.Media.HasUnreadPayload() && !selfMessage,
MediaUnread: media.Sender.HasUnreadPayload() && !selfMessage,
ReactionUnread: false,
}
applyCreateMessageBoxMetadata(&senderArg, senderMeta)
@ -334,7 +403,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
sender := messageFromBoxRow(senderRow)
sender.RandomID = req.RandomID
// 共享媒体索引(0118):发送者侧 box 按媒体类别建索引(peer=收件人)。
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.SenderUserID, req.RecipientUserID, int(senderBoxID), req.Date, req.Media, req.Entities); err != nil {
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.SenderUserID, req.RecipientUserID, int(senderBoxID), req.Date, media.Sender, req.Entities); err != nil {
return domain.SendPrivateTextResult{}, err
}
if err := qtx.UpsertOutboxDialog(ctx, sqlcgen.UpsertOutboxDialogParams{
@ -388,13 +457,13 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
ExpiresAt: int32(expiresAt),
EntitiesJson: entities,
Pts: int32(recipientPts),
MediaJson: mediaJSON,
MediaJson: recipientMediaJSON,
ReplyMarkupJson: replyMarkupJSON,
RichMessageJson: richMessageJSON,
ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID,
Effect: req.Effect,
MediaUnread: req.Media.HasUnreadPayload(),
MediaUnread: media.Recipient.HasUnreadPayload(),
ReactionUnread: false,
}
applyCreateMessageBoxMetadata(&recipientArg, recipientMeta)
@ -405,7 +474,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
recipient = messageFromBoxRow(recipientRow)
recipient.RandomID = req.RandomID
// 共享媒体索引(0118):收件人侧 box 按媒体类别建索引(peer=发送者)。
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.RecipientUserID, req.SenderUserID, int(recipientBoxID), req.Date, req.Media, req.Entities); err != nil {
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.RecipientUserID, req.SenderUserID, int(recipientBoxID), req.Date, media.Recipient, req.Entities); err != nil {
return domain.SendPrivateTextResult{}, err
}
if err := qtx.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{

View file

@ -0,0 +1,50 @@
package postgres
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestModerationFlagsRejectImpossibleStateAtPostgresBoundary(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
user := createTestUser(t, ctx, users, "+1781"+suffix+"71", "ModerationFlags", "")
if _, err := users.SetScamFake(ctx, user.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
t.Fatalf("user store error=%v", err)
}
if _, err := pool.Exec(ctx, `UPDATE users SET scam=true,fake=true WHERE id=$1`, user.ID); err == nil {
t.Fatal("users CHECK constraint accepted scam=true,fake=true")
}
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: user.ID,
Title: "Moderation " + suffix,
Megagroup: true,
Date: 1700002000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
t.Fatalf("channel store error=%v", err)
}
if _, err := pool.Exec(ctx, `UPDATE channels SET scam=true,fake=true WHERE id=$1`, created.Channel.ID); err == nil {
t.Fatal("channels CHECK constraint accepted scam=true,fake=true")
}
gotUser, found, err := users.ByID(ctx, user.ID)
if err != nil || !found || gotUser.Scam || gotUser.Fake {
t.Fatalf("user after rejected writes=%+v found=%v err=%v", gotUser, found, err)
}
gotChannel, err := channels.GetChannelByID(ctx, created.Channel.ID)
if err != nil || gotChannel.Scam || gotChannel.Fake {
t.Fatalf("channel after rejected writes=%+v err=%v", gotChannel, err)
}
}

View file

@ -164,6 +164,21 @@ SET verified = sqlc.arg(verified)::boolean,
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: SetUserScamFake :one
UPDATE users
SET scam = sqlc.arg(scam)::boolean,
fake = sqlc.arg(fake)::boolean,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: SetUserSupport :one
UPDATE users
SET support = sqlc.arg(support)::boolean,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: SweepExpiredPremium :many
UPDATE users
SET premium_expires_at = NULL,

View file

@ -348,6 +348,15 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
l.caches.BotProfiles.InvalidateBotProfileReadModel(evt.PeerID)
}
}
case "user_visibility":
if evt.PeerType == "user" && evt.PeerID != 0 {
if l.caches.RPCProjections != nil {
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.PeerID)
}
if l.caches.Stories != nil {
l.caches.Stories.InvalidateStoryReadModelPeer(domain.Peer{Type: domain.PeerTypeUser, ID: evt.PeerID})
}
}
case "bot_full":
// bot 资料(name/about/description/commands/menu_button)变更经 bot_info_version
// bump 触发(迁移 0013)。channelFullBotInfoCache 按 (viewer,channel) 键、无法按 botID

View file

@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error {
const insertBotUser = `-- name: InsertBotUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version)
VALUES ($1, '', $2, '', $3, '', TRUE, 1)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type InsertBotUserParams struct {
@ -209,6 +209,7 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -216,7 +217,8 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}

View file

@ -34,6 +34,21 @@ type AccountDeletionRequest struct {
UpdatedAt pgtype.Timestamptz
}
type AccountFreezeNotification struct {
ID int64
TargetUserID int64
FrozenUserID int64
Version int64
Frozen bool
Status string
Attempts int32
NextAttemptAt pgtype.Timestamptz
LeaseUntil pgtype.Timestamptz
LastError string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type AccountPassword struct {
UserID int64
HasRecovery bool
@ -90,6 +105,7 @@ type AccountRestriction struct {
FrozenSince pgtype.Timestamptz
FrozenUntil pgtype.Timestamptz
AppealUrl string
Version int64
}
type AccountSetting struct {
@ -394,6 +410,38 @@ type BotEmojiStatusPermission struct {
UpdatedAt pgtype.Timestamptz
}
type BotLoginAllowedUrl struct {
ID int64
BotUserID int64
Kind string
NormalizedUrl string
CreatedAt pgtype.Timestamptz
}
type BotLoginClient struct {
BotUserID int64
ClientID string
ClientSecretHash []byte
SecretVersion int64
SigningAlgorithm string
Enabled bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type BotLoginNativeApp struct {
ID int64
BotUserID int64
Platform string
ApplicationID string
VerificationID string
CallbackUri string
VerifiedDisplayName string
Enabled bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type BotUserPermission struct {
BotUserID int64
UserID int64
@ -504,6 +552,9 @@ type Channel struct {
Wallpaper []byte
Verified bool
LinkedCommunityID int64
Scam bool
Fake bool
Gigagroup bool
}
type ChannelAdminLogEvent struct {
@ -1596,6 +1647,17 @@ type SeedState struct {
UpdatedAt pgtype.Timestamptz
}
type StarGiftAdminGrantCommand struct {
RecipientUserID int64
CommandKey string
RequestFingerprint []byte
SenderUserID int64
GiftID int64
SavedGiftID int64
UniqueGiftID int64
CreatedAt pgtype.Timestamptz
}
type StarGiftAuction struct {
GiftID int64
Slug string
@ -1654,6 +1716,14 @@ type StarGiftAuctionBidPayment struct {
CreatedAt int32
}
type StarGiftBoxMediaRepair struct {
OwnerUserID int64
BoxID int32
PeerType string
PeerID int64
RepairedMedia []byte
}
type StarGiftCatalog struct {
GiftID int64
ActiveRevisionID int64
@ -1768,6 +1838,13 @@ type StarGiftCollectiblePattern struct {
OfficialDocumentID *int64
}
type StarGiftCollectiblePreviewRepair struct {
GiftID int64
CollectibleRevisionID int64
Reason string
RepairedAt pgtype.Timestamptz
}
type StarGiftCollectibleRevision struct {
ID int64
GiftID int64
@ -1823,6 +1900,16 @@ type StarGiftCraftCommand struct {
ChancePermille int32
CreatedAt int32
SourceEditPts []int32
OutputMedia []byte
OutputFingerprint []byte
}
type StarGiftCraftMessageRepair struct {
OwnerUserID int64
BoxID int32
UniqueGiftID int64
DesiredCraftChance int32
DesiredCanCraftAt int32
}
type StarGiftDropDetailsCommand struct {
@ -1885,6 +1972,29 @@ type StarGiftPatternPreviewDocumentRepair struct {
RepairedAt pgtype.Timestamptz
}
type StarGiftPinRepair struct {
ID int64
NewOrder int32
}
type StarGiftPrepaidMessageAlias struct {
OwnerUserID int64
BoxID int32
SavedGiftID int64
MessageSenderID int64
PrivateMessageID int64
}
type StarGiftPrepaidMessageRepair struct {
OwnerUserID int64
BoxID int32
PeerType string
PeerID int64
MessageSenderID int64
PrivateMessageID int64
RepairedMedia []byte
}
type StarGiftPrepaidUpgradeCommand struct {
PayerUserID int64
CommandKey string
@ -1964,6 +2074,14 @@ type StarGiftUpgradeCommand struct {
SourceEditPts int32
}
// Owner-local service-message aliases (unique outputs and separate prepaid-upgrade notifications) for one saved gift aggregate.
type StarGiftUserMessageRef struct {
OwnerUserID int64
MsgID int32
SavedGiftID int64
CreatedAt pgtype.Timestamptz
}
type StarGiftUserPurchase struct {
UserID int64
GiftID int64
@ -1971,6 +2089,14 @@ type StarGiftUserPurchase struct {
UpdatedAt pgtype.Timestamptz
}
type StarGiftUserUniqueMediaRepair struct {
OwnerUserID int64
BoxID int32
PeerType string
PeerID int64
RepairedMedia []byte
}
type StarGiftWithdrawalRequest struct {
ID int64
UniqueGiftID int64
@ -2100,6 +2226,85 @@ type StoryView struct {
UpdatedAt pgtype.Timestamptz
}
type SuggestedPostApproval struct {
MonoforumID int64
SuggestionMessageID int32
ParentChannelID int64
ActorUserID int64
PayerUserID int64
State string
PriceKind string
PriceAmount int64
PriceNanos int32
ScheduleDate int32
ApprovalServiceMessageID int32
PublishedMessageID int32
SettlementDue int32
FinalServiceMessageID int32
CreatedAt int32
UpdatedAt int32
}
type TelegramLoginCode struct {
ID int64
RequestID int64
CodeHash []byte
SealedCode []byte
SealNonce []byte
SealKeyID string
IssuedAt pgtype.Timestamptz
ExpiresAt pgtype.Timestamptz
ConsumedAt pgtype.Timestamptz
}
type TelegramLoginRequest struct {
ID int64
RequestTokenHash []byte
BrowserTokenHash []byte
BotUserID int64
ClientID string
SigningAlgorithm string
Source string
ResponseType string
RedirectUri string
Origin string
Domain string
RequestedScopes []string
OauthState string
Nonce string
CodeChallenge string
CodeChallengeMethod string
Browser string
Platform string
Ip string
Region string
InAppOrigin string
IsApp bool
VerifiedAppName string
MatchCodes []string
MatchCode string
MatchCodesFirst bool
UserIDHint int64
PeerType string
PeerID int64
MessageID int32
ButtonID int32
Status string
AuthorizedUserID *int64
ProfileName string
GivenName string
FamilyName string
PreferredUsername string
Picture string
PhoneNumber string
WriteAllowed bool
PhoneShared bool
CreatedAt pgtype.Timestamptz
ExpiresAt pgtype.Timestamptz
ApprovedAt pgtype.Timestamptz
DeclinedAt pgtype.Timestamptz
}
type TelesrvCollectiblePatternCorrectionEvent struct {
UserID int64
Pts int32
@ -2276,6 +2481,7 @@ type User struct {
BirthdayMonth int32
BirthdayYear int32
PersonalChannelID int64
SignupEmail string
DeletedAt pgtype.Timestamptz
DeletionSource string
DeletionReason string
@ -2283,7 +2489,8 @@ type User struct {
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
LinkedCommunityID int64
SignupEmail string
Scam bool
Fake bool
}
type UserBusinessProfile struct {
@ -2402,6 +2609,24 @@ type UserUpdateWatermark struct {
UpdatedAt pgtype.Timestamptz
}
type WebAuthorization struct {
Hash int64
RequestID int64
UserID int64
BotUserID int64
Domain string
Browser string
Platform string
Ip string
Region string
GrantedScopes []string
PhoneShared bool
BotAccessGranted bool
CreatedAt pgtype.Timestamptz
LastActiveAt pgtype.Timestamptz
RevokedAt pgtype.Timestamptz
}
type WebPage struct {
UrlHash int64
WebPageID int64

View file

@ -14,7 +14,7 @@ import (
const createUser = `-- name: CreateUser :one
INSERT INTO users (access_hash, phone, signup_email, first_name, last_name, username, country_code, premium_expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type CreateUserParams struct {
@ -70,6 +70,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -77,13 +78,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE id = $1
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
@ -119,6 +121,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -126,13 +129,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
const getUserByPhone = `-- name: GetUserByPhone :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE phone = $1 AND deleted_at IS NULL
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake FROM users WHERE phone = $1 AND deleted_at IS NULL
`
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
@ -168,6 +172,7 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -175,13 +180,14 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
const getUserBySignupEmail = `-- name: GetUserBySignupEmail :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> ''
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> ''
`
func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User, error) {
@ -217,6 +223,7 @@ func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User,
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -224,13 +231,14 @@ func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
`
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
@ -266,6 +274,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -273,13 +282,14 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
const getUsersByIDs = `-- name: GetUsersByIDs :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
FROM users
WHERE id = ANY($1::bigint[])
ORDER BY id
@ -324,6 +334,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -331,7 +342,8 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
); err != nil {
return nil, err
}
@ -344,7 +356,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
}
const getUsersByPhones = `-- name: GetUsersByPhones :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
FROM users
WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
ORDER BY id
@ -389,6 +401,7 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -396,7 +409,8 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
); err != nil {
return nil, err
}
@ -592,7 +606,7 @@ UPDATE users
SET premium_expires_at = $1::timestamptz,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type SetUserPremiumUntilParams struct {
@ -633,6 +647,7 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -640,7 +655,130 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
const setUserScamFake = `-- name: SetUserScamFake :one
UPDATE users
SET scam = $1::boolean,
fake = $2::boolean,
updated_at = now()
WHERE id = $3::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type SetUserScamFakeParams struct {
Scam bool
Fake bool
ID int64
}
func (q *Queries) SetUserScamFake(ctx context.Context, arg SetUserScamFakeParams) (User, error) {
row := q.db.QueryRow(ctx, setUserScamFake, arg.Scam, arg.Fake, arg.ID)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
&i.DefaultHistoryTtlPeriod,
&i.IsBot,
&i.BotInfoVersion,
&i.PremiumExpiresAt,
&i.EmojiStatusDocumentID,
&i.EmojiStatusUntil,
&i.ColorSet,
&i.Color,
&i.ColorBackgroundEmojiID,
&i.ProfileColorSet,
&i.ProfileColor,
&i.ProfileColorBackgroundEmojiID,
&i.BirthdayDay,
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
const setUserSupport = `-- name: SetUserSupport :one
UPDATE users
SET support = $1::boolean,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type SetUserSupportParams struct {
Support bool
ID int64
}
func (q *Queries) SetUserSupport(ctx context.Context, arg SetUserSupportParams) (User, error) {
row := q.db.QueryRow(ctx, setUserSupport, arg.Support, arg.ID)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
&i.DefaultHistoryTtlPeriod,
&i.IsBot,
&i.BotInfoVersion,
&i.PremiumExpiresAt,
&i.EmojiStatusDocumentID,
&i.EmojiStatusUntil,
&i.ColorSet,
&i.Color,
&i.ColorBackgroundEmojiID,
&i.ProfileColorSet,
&i.ProfileColor,
&i.ProfileColorBackgroundEmojiID,
&i.BirthdayDay,
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -650,7 +788,7 @@ UPDATE users
SET verified = $1::boolean,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type SetUserVerifiedParams struct {
@ -691,6 +829,7 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -698,7 +837,8 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -715,7 +855,7 @@ WHERE id IN (
ORDER BY premium_expires_at
LIMIT $2::int
)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type SweepExpiredPremiumParams struct {
@ -762,6 +902,7 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -769,7 +910,8 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
); err != nil {
return nil, err
}
@ -788,7 +930,7 @@ SET birthday_day = $1::int,
birthday_year = $3::int,
updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserBirthdayParams struct {
@ -836,6 +978,7 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -843,7 +986,8 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -855,7 +999,7 @@ SET color_set = $1::boolean,
color_background_emoji_id = $3::bigint,
updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserColorParams struct {
@ -903,6 +1047,7 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -910,7 +1055,8 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -923,7 +1069,7 @@ SET emoji_status_document_id = $1::bigint,
emoji_status_collectible = $4::jsonb,
updated_at = now()
WHERE id = $5::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserEmojiStatusParams struct {
@ -973,6 +1119,7 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -980,7 +1127,8 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -1007,7 +1155,7 @@ UPDATE users
SET personal_channel_id = $1::bigint,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserPersonalChannelParams struct {
@ -1048,6 +1196,7 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -1055,7 +1204,8 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -1065,7 +1215,7 @@ UPDATE users
SET phone = $1::text,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserPhoneParams struct {
@ -1106,6 +1256,7 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -1113,7 +1264,8 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -1124,7 +1276,7 @@ SET phone = $1::text,
signup_email = $2::text,
updated_at = now()
WHERE id = $3::bigint
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserPhoneAndSignupEmailParams struct {
@ -1166,6 +1318,7 @@ func (q *Queries) UpdateUserPhoneAndSignupEmail(ctx context.Context, arg UpdateU
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -1173,7 +1326,8 @@ func (q *Queries) UpdateUserPhoneAndSignupEmail(ctx context.Context, arg UpdateU
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -1185,7 +1339,7 @@ SET first_name = $2,
about = $4,
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserProfileParams struct {
@ -1233,6 +1387,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -1240,7 +1395,8 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -1252,7 +1408,7 @@ SET profile_color_set = $1::boolean,
profile_color_background_emoji_id = $3::bigint,
updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserProfileColorParams struct {
@ -1300,6 +1456,7 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -1307,7 +1464,8 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -1317,7 +1475,7 @@ UPDATE users
SET username = $2,
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserUsernameParams struct {
@ -1358,6 +1516,7 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.SignupEmail,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
@ -1365,7 +1524,8 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
&i.Scam,
&i.Fake,
)
return i, err
}

View file

@ -649,11 +649,17 @@ 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
query = `SELECT ref.msg_id, COALESCE(u.slug, ''), p.id
FROM peer_star_gifts p
LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id
CROSS JOIN LATERAL (
SELECT p.msg_id::bigint AS msg_id
UNION ALL
SELECT r.msg_id::bigint FROM star_gift_user_message_refs r
WHERE r.saved_gift_id=p.id AND r.owner_user_id=p.owner_peer_id
) ref
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[])
AND (ref.msg_id=ANY($3::bigint[])
OR u.slug=ANY($4::text[]))`
}
rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs)
@ -744,15 +750,42 @@ func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGift
if !ref.Valid() {
return false, domain.ErrStarGiftNotFound
}
where, args := savedStarGiftRefWhere(ref)
args = append(args, unsaved)
tag, err := s.db.Exec(ctx, `
UPDATE peer_star_gifts SET unsaved = $4
WHERE `+where+` AND lifecycle_status='active'`, args...)
changed := false
err := withTx(ctx, s.db, "set star gift unsaved", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil {
return err
}
where, args := savedStarGiftRefWhere(ref)
var savedID int64
var pinnedOrder int
err := tx.QueryRow(ctx, `SELECT id,pinned_order FROM peer_star_gifts WHERE `+where+` AND lifecycle_status='active' FOR UPDATE`, args...).Scan(&savedID, &pinnedOrder)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET unsaved=$2,pinned_order=CASE WHEN $2 THEN 0 ELSE pinned_order END WHERE id=$1`, savedID, unsaved); err != nil {
return err
}
if unsaved && pinnedOrder > 0 {
// The positive-order unique index is immediate. Move the bounded
// vector one vacant slot at a time so no transient duplicate order
// can be observed by PostgreSQL.
for order := pinnedOrder + 1; order <= domain.MaxPinnedStarGifts; order++ {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$4
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order=$3`, string(ref.Owner.Type), ref.Owner.ID, order, order-1); err != nil {
return err
}
}
}
changed = true
return nil
})
if err != nil {
return false, fmt.Errorf("set star gift unsaved: %w", err)
}
return tag.RowsAffected() > 0, nil
return changed, nil
}
func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
@ -836,7 +869,11 @@ func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) {
return "owner_peer_type = $1 AND owner_peer_id = $2 AND saved_id = $3", args
default:
args = append(args, ref.MsgID)
return "owner_peer_type = $1 AND owner_peer_id = $2 AND msg_id = $3", args
return `owner_peer_type = $1 AND owner_peer_id = $2 AND (
msg_id = $3 OR EXISTS (
SELECT 1 FROM star_gift_user_message_refs r
WHERE r.saved_gift_id = id AND r.owner_user_id = $2 AND r.msg_id = $3
))`, args
}
}

View file

@ -690,6 +690,9 @@ func (s *StarGiftStore) ReorderCollections(ctx context.Context, owner domain.Pee
}
func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
if len(savedGiftIDs) > domain.MaxPinnedStarGifts {
return domain.ErrStarGiftCollectibleInvalid
}
return withTx(ctx, s.db, "set pinned star gifts", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
@ -698,11 +701,14 @@ func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedG
if err != nil {
return err
}
if len(ids) != len(savedGiftIDs) {
return domain.ErrStarGiftCollectibleInvalid
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=0 WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order<>0`, string(owner.Type), owner.ID); err != nil {
return err
}
for order, id := range ids {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2 WHERE id=$1`, id, order+1); err != nil {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2,unsaved=false WHERE id=$1`, id, order+1); err != nil {
return err
}
}

View file

@ -43,24 +43,29 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
Document: collectibleTestDocumentPtr(baseDocumentID+3, "crafted-model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+3, "crafted-model"), Animation: collectibleTestAnimationPtr("crafted-model.tgs"),
OfficialDocumentID: 5100000000000000003,
}, {
Kind: domain.StarGiftCollectibleModel, Name: "Solar", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 78,
Document: collectibleTestDocumentPtr(baseDocumentID+4, "model-two.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+4, "model-two"), Animation: collectibleTestAnimationPtr("model-two.tgs"),
OfficialDocumentID: 5100000000000000004,
}},
Patterns: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 989,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "pattern.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+2, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs"),
}},
Backdrops: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1,
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 999,
}},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 989,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Rings", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 11,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+5, "pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+5, "pattern-two"), Animation: collectibleTestAnimationPtr("pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 999},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Daylight", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1},
},
Actor: "integration", CommandID: "collectibles-" + suffix,
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: make([]byte, 32),
})
if err != nil {
t.Fatalf("publish collectible pool: %v", err)
}
if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 2 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 ||
if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 3 || len(poolRevision.Patterns) != 2 || len(poolRevision.Backdrops) != 2 ||
!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)
@ -116,8 +121,27 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
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)
if uniqueAction.SavedID != 0 || uniqueAction.Peer.Type != "" || uniqueAction.Peer.ID != 0 {
t.Fatalf("user unique action leaked channel peer/saved_id: %+v", uniqueAction)
}
senderUniqueAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
if senderUniqueAction == nil || senderUniqueAction.SavedID != 0 {
t.Fatalf("sender unique action leaked owner-only saved_id: %+v", senderUniqueAction)
}
if byOutput, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: ownerMessage.ID}); err != nil || !found || byOutput.ID != savedID {
t.Fatalf("owner upgrade output ref = %+v found %v err %v", byOutput, found, err)
}
var ownerAliasCount, senderAliasCount int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, owner.ID, ownerMessage.ID, savedID).Scan(&ownerAliasCount); err != nil {
t.Fatalf("load owner upgrade output alias: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2`, sender.ID, ownerMessage.ID).Scan(&senderAliasCount); err != nil {
t.Fatalf("load sender upgrade output alias: %v", err)
}
if ownerAliasCount != 1 || senderAliasCount != 0 {
t.Fatalf("upgrade output aliases owner=%d sender=%d, want owner-only", ownerAliasCount, senderAliasCount)
}
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil ||
@ -334,21 +358,22 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
}
soldOutRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: soldOutEntry.Gift.ID, UpgradeStars: 10, SupplyTotal: 1, SlugPrefix: "nova-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs"),
}},
Patterns: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs"),
}},
Backdrops: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2,
CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff,
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
}},
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Nova Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+103, "nova-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+103, "nova-model-two"), Animation: collectibleTestAnimationPtr("nova-model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Ray Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+104, "nova-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+104, "nova-pattern-two"), Animation: collectibleTestAnimationPtr("nova-pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2, CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Light", BackdropID: 3, CenterColor: 0xeeeeee, EdgeColor: 0xcccccc, PatternColor: 0xaaaaaa, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
},
Actor: "integration", CommandID: "soldout-pool-" + suffix,
})
if err != nil {
@ -418,6 +443,65 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
}
}
func TestStarGiftCollectiblePreviewActivationGuardPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
gifts := NewStarGiftStore(pool)
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Unsafe Preview " + suffix, Stars: 10, ConvertStars: 5, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "unsafe-preview.tgs"), Blob: collectibleTestBlob(baseDocumentID, "unsafe-preview"),
Animation: collectibleTestAnimation("unsafe-preview.tgs"), Actor: "integration", CommandID: "unsafe-preview-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create unsafe-preview catalog: %v", err)
}
var revisionID int64
if err := pool.QueryRow(ctx, `
INSERT INTO star_gift_collectible_revisions
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id)
VALUES ($1, 1, 10, 10, $2, 'draft', 'integration', $3)
RETURNING id`, entry.Gift.ID, "unsafe-"+suffix, "unsafe-preview-pool-"+suffix).Scan(&revisionID); err != nil {
t.Fatalf("insert unsafe-preview revision: %v", err)
}
animation := collectibleTestAnimation("unsafe-preview-attribute.tgs")
if _, err := pool.Exec(ctx, `
INSERT INTO star_gift_collectible_models
(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, crafted, sort_order)
VALUES ($1, 'Only Model', $2, $3::jsonb, $4, 'model.tgs', 'tgs', 512, 512, 30, 0, 60, 'permille', 1000, false, 0)`,
revisionID, baseDocumentID, string(animation.JSON), animation.SHA256); err != nil {
t.Fatalf("insert unsafe-preview model: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO star_gift_collectible_patterns
(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, sort_order)
VALUES ($1, 'Only Pattern', $2, $3::jsonb, $4, 'pattern.tgs', 'tgs', 512, 512, 30, 0, 60, 'permille', 1000, 0)`,
revisionID, baseDocumentID, string(animation.JSON), animation.SHA256); err != nil {
t.Fatalf("insert unsafe-preview pattern: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO star_gift_collectible_backdrops
(collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color, text_color,
rarity_kind, rarity_permille, sort_order)
VALUES ($1, 'Only Backdrop', 1, 1, 2, 3, 4, 'permille', 1000, 0)`, revisionID); err != nil {
t.Fatalf("insert unsafe-preview backdrop: %v", err)
}
if _, err := pool.Exec(ctx, `
UPDATE star_gift_collectible_revisions SET status='published', published_at=now() WHERE id=$1`, revisionID); err != nil {
t.Fatalf("publish unsafe-preview revision directly: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE star_gift_catalog SET collectible_revision_id=$2 WHERE gift_id=$1`, entry.Gift.ID, revisionID); err == nil {
t.Fatal("database activated a collectible preview pool with one client-distinct item per category")
}
var activeRevisionID *int64
if err := pool.QueryRow(ctx, `SELECT collectible_revision_id FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&activeRevisionID); err != nil || activeRevisionID != nil {
t.Fatalf("unsafe preview activation pointer=%v err=%v, want null", activeRevisionID, err)
}
}
func TestStarGiftUpgradeWithoutCraftedModelDoesNotAdvertiseCraft(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
@ -441,27 +525,28 @@ func TestStarGiftUpgradeWithoutCraftedModelDoesNotAdvertiseCraft(t *testing.T) {
}
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: collectibleTestPatternDocumentPtr(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,
}},
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Ordinary", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "no-craft-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "no-craft-model"), Animation: collectibleTestAnimationPtr("no-craft-model.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Ordinary Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+3, "no-craft-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "no-craft-model-two"), Animation: collectibleTestAnimationPtr("no-craft-model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "no-craft-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "no-craft-pattern"), Animation: collectibleTestAnimationPtr("no-craft-pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+4, "no-craft-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "no-craft-pattern-two"), Animation: collectibleTestAnimationPtr("no-craft-pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
},
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 {
if len(revision.Models) != 2 || revision.Models[0].Crafted || revision.Models[1].Crafted {
t.Fatalf("no-craft pool models = %+v", revision.Models)
}
@ -518,6 +603,125 @@ FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE p
}
}
func TestAdminUniqueStarGiftGrantIsAtomicAndReplayable(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
recipient := createTestUser(t, ctx, NewUserStore(pool), "+1780"+suffix+"61", "AdminGiftRecipient", "")
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Admin Grant " + suffix, Stars: 50, ConvertStars: 25, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "admin-grant-gift.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "admin-grant-gift"), Animation: collectibleTestAnimation("admin-grant-gift.tgs"),
Actor: "integration", CommandID: "admin-grant-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create admin grant catalog gift: %v", err)
}
revision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 2, SlugPrefix: "admin-grant-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Model One", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "admin-model-one.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "admin-model-one"), Animation: collectibleTestAnimationPtr("admin-model-one.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+2, "admin-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "admin-model-two"), Animation: collectibleTestAnimationPtr("admin-model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern One", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "admin-pattern-one.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "admin-pattern-one"), Animation: collectibleTestAnimationPtr("admin-pattern-one.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+4, "admin-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "admin-pattern-two"), Animation: collectibleTestAnimationPtr("admin-pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop One", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
},
Actor: "integration", CommandID: "admin-grant-pool-" + suffix,
})
if err != nil {
t.Fatalf("publish admin grant collectible pool: %v", err)
}
upgrades := NewStarGiftUpgradeStore(pool, NewMessageStore(pool))
invalid := domain.AdminStarGiftGrant{
SenderID: domain.OfficialSystemUserID, Recipient: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID},
GiftID: entry.Gift.ID, Upgrade: true, CommandKey: "admin-invalid-" + suffix, Date: now,
ModelAttributeID: revision.Models[0].ID + 9_999_999,
}
if _, err := upgrades.GrantUniqueStarGift(ctx, invalid); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("invalid admin grant error=%v", err)
}
var issued, messageCount, savedCount, uniqueCount, commandCount int
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, revision.ID).Scan(&issued); err != nil {
t.Fatal(err)
}
invalidRandomID := lifecycleCommandRandomID("admin-collectible-grant", recipient.ID, invalid.CommandKey)
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM private_messages WHERE sender_user_id=$1 AND random_id=$2`,
domain.OfficialSystemUserID, invalidRandomID).Scan(&messageCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type='user' AND owner_peer_id=$1 AND gift_id=$2`,
recipient.ID, entry.Gift.ID).Scan(&savedCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM unique_star_gifts WHERE gift_id=$1`, entry.Gift.ID).Scan(&uniqueCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_admin_grant_commands WHERE recipient_user_id=$1`,
recipient.ID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if issued != 0 || messageCount != 0 || savedCount != 0 || uniqueCount != 0 || commandCount != 0 {
t.Fatalf("failed grant leaked state: issued=%d messages=%d saved=%d unique=%d commands=%d",
issued, messageCount, savedCount, uniqueCount, commandCount)
}
req := invalid
req.CommandKey = "admin-success-" + suffix
req.Message = "atomic collectible"
req.ModelAttributeID = revision.Models[0].ID
req.PatternAttributeID = revision.Patterns[0].ID
req.BackdropAttributeID = revision.Backdrops[0].ID
granted, err := upgrades.GrantUniqueStarGift(ctx, req)
if err != nil {
t.Fatalf("grant admin unique gift: %v", err)
}
action := granted.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if granted.Duplicate || granted.Saved.MsgID <= 0 || granted.Saved.MsgID != granted.Saved.UpgradeMsgID ||
granted.Saved.UniqueGiftID != granted.Unique.ID || granted.Unique.Num != 1 ||
action == nil || !action.Assigned || !action.Saved || action.Gift.ID != granted.Unique.ID {
t.Fatalf("admin grant result=%+v action=%+v", granted, action)
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, granted.Send.RecipientMessage.Pts-1, 1)
if err != nil || len(events) != 1 || events[0].Message.Media == nil ||
events[0].Message.Media.ServiceAction == nil ||
events[0].Message.Media.ServiceAction.StarGiftUnique == nil ||
events[0].Message.Media.ServiceAction.StarGiftUnique.Gift.ID != granted.Unique.ID {
t.Fatalf("admin grant durable update=%+v err=%v", events, err)
}
replay, err := upgrades.GrantUniqueStarGift(ctx, req)
if err != nil {
t.Fatalf("replay admin unique gift: %v", err)
}
if !replay.Duplicate || replay.Saved.ID != granted.Saved.ID || replay.Unique.ID != granted.Unique.ID ||
replay.Send.RecipientMessage.ID != granted.Send.RecipientMessage.ID {
t.Fatalf("admin grant replay=%+v want saved=%d unique=%d msg=%d",
replay, granted.Saved.ID, granted.Unique.ID, granted.Send.RecipientMessage.ID)
}
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, revision.ID).Scan(&issued); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_admin_grant_commands WHERE recipient_user_id=$1`,
recipient.ID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if issued != 1 || commandCount != 1 {
t.Fatalf("replay duplicated aggregate: issued=%d commands=%d", issued, commandCount)
}
}
func collectibleTestAnimation(name string) domain.StarGiftAnimation {
return domain.StarGiftAnimation{
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,

View file

@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
)
const (
@ -18,6 +19,37 @@ const (
maxStarGiftAuctionAcquired = 1000
)
// starGiftCraftOutputIntent is the immutable message intent committed with a
// successful craft outcome. The aggregate may subsequently be hidden, listed
// or otherwise edited; an exact retry must still use the first intent and its
// fingerprint instead of rebuilding a different message from mutable state.
type starGiftCraftOutputIntent struct {
Media *domain.MessageMedia
Fingerprint []byte
Date int
SavedGiftID int64
}
func starGiftCraftOutputMedia(userID int64, gift domain.UniqueStarGift, saved domain.SavedStarGift) *domain.MessageMedia {
return &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: gift, FromUserID: userID, Saved: !saved.Unsaved, Craft: true,
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt,
CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars,
CanCraftAt: saved.CanCraftAt,
},
}}
}
func starGiftCraftOutputRequest(req domain.StarGiftCraftRequest, intent starGiftCraftOutputIntent) domain.SendPrivateTextRequest {
return domain.SendPrivateTextRequest{
SenderUserID: req.UserID, RecipientUserID: req.UserID,
RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: intent.Date,
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID,
IdempotencyFingerprint: append([]byte(nil), intent.Fingerprint...), Media: intent.Media,
}
}
func defaultStarGiftCraftDraw(upper int) (int, error) {
if upper <= 0 {
return 0, domain.ErrStarGiftCraftUnavailable
@ -158,7 +190,8 @@ func (s *StarGiftLifecycleStore) ListCraftStarGifts(ctx context.Context, userID,
}
args := []any{userID, giftID}
where := `p.owner_peer_type='user' AND p.owner_peer_id=$1 AND p.gift_id=$2
AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer
AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL
AND p.can_craft_at>0 AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer
AND NOT u.burned AND u.owner_address='' AND u.craft_chance_permille>0
AND EXISTS (SELECT 1 FROM star_gift_collectible_models m
WHERE m.collectible_revision_id=u.collectible_revision_id AND m.crafted)`
@ -234,11 +267,11 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
// A committed failed craft has already moved every input out of the active
// lifecycle. Consult the immutable receipt before active-gift resolution so
// an exact transport retry can still replay the same terminal result.
if replay, found, err := s.loadCraftReplay(ctx, req); err != nil || found {
if replay, output, found, err := s.loadCraftReplay(ctx, req); err != nil || found {
if err != nil || !replay.Success {
return replay, err
}
return s.deliverCraftSuccess(ctx, req, replay)
return s.deliverCraftSuccess(ctx, req, replay, output)
}
savedIDs, err := NewStarGiftStore(s.db).ResolveSavedIDs(ctx, owner, req.Refs)
if err != nil {
@ -248,7 +281,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
var result domain.StarGiftCraftResult
var resultUniqueID int64
var output starGiftCraftOutputIntent
err = withTx(ctx, s.db, "craft star gift", func(tx pgx.Tx) error {
lockedRows, err := tx.Query(ctx, `SELECT id FROM peer_star_gifts WHERE id=ANY($1::bigint[]) ORDER BY id FOR UPDATE`, sortedUniqueInt64(savedIDs))
if err != nil {
@ -269,7 +302,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
chance := 0
for i := range req.Refs {
saved, err := lockSavedStarGiftByID(ctx, tx, savedIDs[i])
if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt > req.Date {
if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt <= 0 || saved.CanCraftAt > req.Date {
return domain.ErrStarGiftCraftUnavailable
}
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID)
@ -374,111 +407,164 @@ WHERE id=ANY($1::bigint[])`, savedIDs[burnFrom:]); err != nil {
}
result.SourceEdits = sourceEdits
var resultID any
var outputMediaJSON any
var outputFingerprint any
if result.Success {
resultID = firstUniqueID
resultUniqueID = firstUniqueID
gift, found, err := NewStarGiftStore(tx).UniqueByID(ctx, firstUniqueID)
if err != nil || !found {
if err != nil {
return err
}
return domain.ErrStarGiftCraftUnavailable
}
saved, found, err := savedStarGiftByID(ctx, tx, firstSavedID)
if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != gift.ID ||
!saved.LifecycleStatus.Live() || gift.Owner != owner || gift.Burned || !gift.Crafted {
if err != nil {
return err
}
return domain.ErrStarGiftCraftUnavailable
}
media := starGiftCraftOutputMedia(req.UserID, gift, saved)
intent := starGiftCraftOutputIntent{Media: media, Date: req.Date, SavedGiftID: saved.ID}
fingerprint, err := store.PrivateSendFingerprint(starGiftCraftOutputRequest(req, intent))
if err != nil {
return fmt.Errorf("fingerprint crafted gift output: %w", err)
}
mediaJSON, err := encodeMessageMedia(media)
if err != nil {
return fmt.Errorf("encode crafted gift output: %w", err)
}
intent.Fingerprint = fingerprint
output = intent
outputMediaJSON = mediaJSON
outputFingerprint = fingerprint
giftCopy := gift
result.Gift = &giftCopy
}
_, err = tx.Exec(ctx, `INSERT INTO star_gift_craft_commands(user_id,command_key,input_unique_gift_ids,gift_id,
success,result_unique_gift_id,chance_permille,created_at,source_edit_pts) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID,
strings.TrimSpace(req.CommandKey), uniqueIDs, giftID, result.Success, resultID, chance, req.Date, sourceEditPTS)
success,result_unique_gift_id,chance_permille,created_at,source_edit_pts,output_media,output_fingerprint)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, req.UserID, strings.TrimSpace(req.CommandKey), uniqueIDs,
giftID, result.Success, resultID, chance, req.Date, sourceEditPTS, outputMediaJSON, outputFingerprint)
return err
})
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found {
if replay, replayOutput, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found {
if replayErr == nil && found && replay.Success {
return s.deliverCraftSuccess(ctx, req, replay, replayOutput)
}
return replay, replayErr
}
}
return domain.StarGiftCraftResult{}, err
}
if result.Success {
gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, resultUniqueID)
if err != nil || !found {
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
result.Gift = &gift
}
if result.Success {
return s.deliverCraftSuccess(ctx, req, result)
return s.deliverCraftSuccess(ctx, req, result, output)
}
return result, nil
}
func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult) (domain.StarGiftCraftResult, error) {
if result.Gift == nil || s.messages == nil {
func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult, output starGiftCraftOutputIntent) (domain.StarGiftCraftResult, error) {
if result.Gift == nil || s.messages == nil || output.Media == nil || output.Date <= 0 || output.SavedGiftID <= 0 ||
store.ValidateSendFingerprint(output.Fingerprint, "crafted gift output") != nil {
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
saved, found, err := savedStarGiftByUniqueID(ctx, s.db, result.Gift.ID)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
sent, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: req.UserID,
RecipientUserID: req.UserID, RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: *result.Gift, FromUserID: req.UserID, Peer: saved.Owner, Saved: !saved.Unsaved, Craft: true,
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt,
CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars,
CanCraftAt: saved.CanCraftAt}}}})
messageReq := starGiftCraftOutputRequest(req, output)
hooks := privateSendTxHooks{after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
return registerUserStarGiftMessageRef(ctx, tx, req.UserID, sent.SenderMessage.ID, output.SavedGiftID, result.Gift.ID)
}}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
return domain.StarGiftCraftResult{}, err
}
registered, err := userStarGiftMessageRefMatches(ctx, s.db, req.UserID, sent.SenderMessage.ID, output.SavedGiftID)
if err != nil || !registered {
if err != nil {
return domain.StarGiftCraftResult{}, err
}
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
result.Send = sent
result.Duplicate = result.Duplicate || sent.Duplicate
return result, nil
}
func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, bool, error) {
func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, starGiftCraftOutputIntent, bool, error) {
var success bool
var resultID *int64
var chance int
var inputUniqueIDs []int64
var sourceEditPTS []int32
err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts
var createdAt int
var outputMediaJSON, outputFingerprint []byte
err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts,
created_at,output_media,output_fingerprint
FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`,
req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS)
req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS,
&createdAt, &outputMediaJSON, &outputFingerprint)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftCraftResult{}, false, nil
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, nil
}
if err != nil {
return domain.StarGiftCraftResult{}, false, err
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
}
if len(req.Refs) != len(inputUniqueIDs) || len(req.Refs) != len(sourceEditPTS) {
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
savedIDs := make([]int64, 0, len(inputUniqueIDs))
owner := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}
for i, uniqueID := range inputUniqueIDs {
saved, found, err := savedStarGiftByUniqueID(ctx, s.db, uniqueID)
if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != uniqueID {
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
ref := req.Refs[i]
if ref.Owner != owner || ref.Slug == "" && ref.MsgID != saved.MsgID {
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
if ref.Owner != owner {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
if ref.Slug != "" {
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID)
if err != nil || !found || !strings.EqualFold(ref.Slug, unique.Slug) {
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
} else if ref.MsgID != saved.MsgID {
matches, err := userStarGiftMessageRefMatches(ctx, s.db, req.UserID, ref.MsgID, saved.ID)
if err != nil {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
}
if !matches {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
}
savedIDs = append(savedIDs, saved.ID)
}
sourceEdits, err := s.loadCraftInputMessageReplays(ctx, req, savedIDs, sourceEditPTS)
if err != nil {
return domain.StarGiftCraftResult{}, false, err
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
}
result := domain.StarGiftCraftResult{Success: success, Chance: chance, SourceEdits: sourceEdits, Duplicate: true}
if resultID != nil {
gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, *resultID)
if err != nil || !found {
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
if !success {
if resultID != nil || len(outputMediaJSON) != 0 || len(outputFingerprint) != 0 {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
result.Gift = &gift
return result, starGiftCraftOutputIntent{}, true, nil
}
return result, true, nil
if resultID == nil || createdAt <= 0 || store.ValidateSendFingerprint(outputFingerprint, "crafted gift replay") != nil {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
media, err := decodeMessageMedia(string(outputMediaJSON))
if err != nil || media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil ||
!media.ServiceAction.StarGiftUnique.Craft || media.ServiceAction.StarGiftUnique.Gift.ID != *resultID {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
gift := media.ServiceAction.StarGiftUnique.Gift
result.Gift = &gift
output := starGiftCraftOutputIntent{Media: media, Fingerprint: append([]byte(nil), outputFingerprint...),
Date: createdAt, SavedGiftID: savedIDs[0]}
return result, output, true, nil
}
func chooseCraftedModel(ctx context.Context, tx pgx.Tx, revisionID int64) (int64, error) {

View file

@ -149,7 +149,10 @@ WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxI
return nil, 0, fmt.Errorf("enqueue craft input edit: %w", err)
}
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
privateMediaJSON = mediaJSON
privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media)
if err != nil {
return nil, 0, err
}
}
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
}

View file

@ -134,9 +134,18 @@ VALUES($1,$2,$3,$4,$5,$6,$7)`, req.PayerUserID, req.CommandKey, locked.ID, req.F
locked.PrepaidUpgradeStars, locked.PrepaidUpgradeHash = req.ChargeStars, ""
result.Saved, result.Balance = locked, balance
return nil
}, projectMedia: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) (privateSendMediaProjection, error) {
if result.Saved.Owner.Type != domain.PeerTypeUser {
return privateSendMediaProjection{Shared: messageReq.Media, Sender: messageReq.Media, Recipient: messageReq.Media}, nil
}
return projectPrivateStarGiftSourceRef(ctx, tx, messageReq, result.Saved.Owner.ID, result.Saved.MsgID)
}, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
if req.Owner.Type != domain.PeerTypeChannel {
return nil
if req.Owner.Type == domain.PeerTypeUser {
ownerMessageID := sent.RecipientMessage.ID
if sent.SenderMessage.OwnerUserID == req.Owner.ID {
ownerMessageID = sent.SenderMessage.ID
}
return registerUserStarGiftMessageRef(ctx, tx, req.Owner.ID, ownerMessageID, result.Saved.ID, 0)
}
action := messageReq.Media.ServiceAction.StarGift
return NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.Owner.ID, req.PayerUserID,

View file

@ -142,6 +142,26 @@ func TestStarGiftStorePostgres(t *testing.T) {
if !slices.Equal(gotMsgIDs, wantMsgIDs) {
t.Fatalf("pinned paged msg ids = %v, want %v", gotMsgIDs, wantMsgIDs)
}
// Hiding a pinned gift atomically unpins it and compacts the remaining
// vector. Pinning it again makes it visible in the same owner transaction.
if ok, err := st.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}, true); err != nil || !ok {
t.Fatalf("hide pinned gift = %v err %v", ok, err)
}
hiddenPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
if err != nil || !found || !hiddenPinned.Unsaved || hiddenPinned.PinnedOrder != 0 {
t.Fatalf("hidden pinned gift = %+v found %v err %v", hiddenPinned, found, err)
}
remainingPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 102})
if err != nil || !found || remainingPinned.PinnedOrder != 1 {
t.Fatalf("remaining pin after compaction = %+v found %v err %v", remainingPinned, found, err)
}
if err := st.SetPinned(ctx, ownerPeer, []int64{savedIDs[0], savedIDs[2]}); err != nil {
t.Fatalf("repin hidden gift: %v", err)
}
repinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
if err != nil || !found || repinned.Unsaved || repinned.PinnedOrder != 1 {
t.Fatalf("repinned hidden gift = %+v found %v err %v", repinned, found, err)
}
if err := st.SetPinned(ctx, ownerPeer, nil); err != nil {
t.Fatalf("clear pinned profile order: %v", err)
}

View file

@ -389,6 +389,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
}},
}
var result domain.StarGiftTransferResult
var sourceSaved domain.SavedStarGift
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
saved, unique, err := lockTransferableStarGift(ctx, tx, req.ActorUserID, req.Ref, req.Date)
@ -414,6 +415,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil {
return err
}
sourceSaved = saved
unique.Owner = req.To
saved.Owner = req.To
result.Saved, result.Unique, result.Balance = saved, unique, balance
@ -433,6 +435,9 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date); err != nil {
return err
}
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_transfer_commands(actor_user_id,command_key,unique_gift_id,
from_peer_type,from_peer_id,to_peer_type,to_peer_id,charge_stars,balance_after,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.ActorUserID, strings.TrimSpace(req.CommandKey), result.Unique.ID,
@ -441,6 +446,10 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
}
result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, 0, msgID, req.Date
result.Saved.FromUserID = req.ActorUserID
if sourceSaved.Owner.Type == domain.PeerTypeUser {
_, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date)
return err
}
return nil
},
}
@ -502,6 +511,7 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
}
var result domain.StarGiftTransferResult
var commissionAmount int64
var sourceSaved domain.SavedStarGift
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
var listingCurrency, sellerType string
@ -557,12 +567,15 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
gift.ResellAmount = nil
gift.LastSaleDate = req.Date
gift.LastSaleAmount = &domain.StarGiftAmount{Currency: req.Amount.Currency, Amount: req.Amount.Amount}
sourceSaved = saved
saved.Owner = req.To
if req.To.Type == domain.PeerTypeChannel {
saved.MsgID, saved.SavedID = 0, saved.ID
}
result.Saved, result.Unique, result.Balance = saved, gift, balance
send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(gift, messageSenderID, req.To, saved)
resaleAmount := req.Amount
send.Media.ServiceAction.StarGiftUnique.ResaleAmount = &resaleAmount
return nil
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
@ -578,6 +591,11 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date); err != nil {
return err
}
if req.To.Type == domain.PeerTypeUser {
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id,
buyer_peer_type,buyer_peer_id,currency,amount,commission_amount,sold_at,command_key)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, result.Unique.ID, string(seller.Type), seller.ID,
@ -601,6 +619,11 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
}
result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, savedID, msgID, req.Date
result.Saved.FromUserID = messageSenderID
if sourceSaved.Owner.Type == domain.PeerTypeUser {
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date); err != nil {
return err
}
}
return updateStarGiftResaleProjection(ctx, tx, result.Unique.GiftID)
},
}
@ -803,7 +826,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
offer.Gift = gift
actionKind := domain.MessageServiceActionStarGiftUnique
action := &domain.MessageServiceAction{Kind: actionKind, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: gift, FromUserID: req.OwnerUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: offer.BuyerUserID},
Gift: gift, FromUserID: req.OwnerUserID,
Transferred: true, FromOffer: true, Saved: true,
}}
if req.Decline {
@ -816,6 +839,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}}
var result domain.StarGiftOfferResult
var commissionAmount int64
var sourceSaved domain.SavedStarGift
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
locked, err := scanStarGiftOffer(tx.QueryRow(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id,
@ -874,10 +898,15 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
current.LastSaleDate = req.Date
current.LastSaleAmount = &locked.Price
locked.Status, locked.ResolvedAt, locked.Gift = "accepted", req.Date, current
sourceSaved = saved
saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: locked.BuyerUserID}
result.Offer = locked
result.Unique = current
result.Saved = saved
send.Media.ServiceAction.StarGiftUnique.Gift = current
send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(current, req.OwnerUserID, saved.Owner, saved)
send.Media.ServiceAction.StarGiftUnique.FromOffer = true
resaleAmount := locked.Price
send.Media.ServiceAction.StarGiftUnique.ResaleAmount = &resaleAmount
return nil
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
@ -890,6 +919,10 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
WHERE id=$1`, result.Saved.ID, result.Offer.BuyerUserID, req.OwnerUserID, msgID, req.Date); err != nil {
return err
}
if err := registerUserStarGiftMessageRef(ctx, tx, result.Offer.BuyerUserID, msgID,
result.Saved.ID, result.Unique.ID); err != nil {
return err
}
result.Saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: result.Offer.BuyerUserID}
result.Saved.FromUserID, result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = req.OwnerUserID, msgID, 0, msgID, req.Date
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id,
@ -899,6 +932,9 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
req.Date, fmt.Sprintf("offer:%d", result.Offer.ID)); err != nil {
return err
}
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date); err != nil {
return err
}
return updateStarGiftResaleProjection(ctx, tx, result.Offer.Gift.GiftID)
},
}
@ -1077,6 +1113,7 @@ func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx conte
if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil {
return err
}
sourceSaved := saved
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type='channel',owner_peer_id=$2,updated_at=now() WHERE id=$1`, unique.ID, req.To.ID); err != nil {
return err
}
@ -1100,6 +1137,11 @@ func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx conte
return err
}
result.Saved, result.Unique, result.Balance = saved, unique, balance
if sourceSaved.Owner.Type == domain.PeerTypeUser {
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, unique, req.Date); err != nil {
return err
}
}
return nil
})
return result, err
@ -1148,10 +1190,23 @@ func ensureNoStarGiftMarketConflict(ctx context.Context, tx pgx.Tx, uniqueID int
}
func transferUniqueAction(unique domain.UniqueStarGift, fromUserID int64, to domain.Peer, saved domain.SavedStarGift) *domain.MessageStarGiftUniqueAction {
return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: to,
SavedID: saved.SavedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt,
peer := to
savedID := saved.SavedID
canCraftAt := saved.CanCraftAt
if to.Type == domain.PeerTypeUser {
// peer and saved_id are a shared channel-only TL flag. A user-owned
// transferred gift is managed by this action message's id.
peer = domain.Peer{}
savedID = 0
} else {
// Preserve the durable entitlement for a future transfer back to a user,
// but keep channel Craft hidden until its write/update path exists.
canCraftAt = 0
}
return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: peer,
SavedID: savedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt,
TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt}
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: canCraftAt}
}
func (s *StarGiftLifecycleStore) debitLifecycleAmount(ctx context.Context, tx pgx.Tx, userID int64, amount domain.StarGiftAmount,
@ -1461,15 +1516,22 @@ WHERE provider_request_id=$1 FOR UPDATE`, providerRequestID).Scan(&uniqueID, &ow
requestHash := sha256.Sum256([]byte(providerRequestID))
giftAddress := fmt.Sprintf("telesrv-gift:%s:%x", unique.Slug, requestHash[:8])
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type=NULL,owner_peer_id=NULL,
owner_address=$2,gift_address=$3,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil {
owner_address=$2,gift_address=$3,craft_chance_permille=0,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0 WHERE id=$1`, saved.ID); err != nil {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0,can_craft_at=0 WHERE id=$1`, saved.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests SET status='completed',completed_at=$2 WHERE provider_request_id=$1`, providerRequestID, date); err != nil {
return err
}
unique.Owner = domain.Peer{}
unique.OwnerAddress = ownerAddress
unique.GiftAddress = giftAddress
unique.CraftChancePermille = 0
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, saved, unique, date); err != nil {
return err
}
return updateStarGiftResaleProjection(ctx, tx, unique.GiftID)
})
if err != nil {

View file

@ -7,6 +7,9 @@ import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/deploy"
"telesrv/internal/domain"
)
@ -21,10 +24,11 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
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", "")
prepayPayer := createTestUser(t, ctx, users, "+1881"+suffix+"06", "PrepayPayer", "")
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
stars := NewStarsStore(pool)
for _, user := range []domain.User{buyer, owner, offerBuyer, resaleBuyer, loser} {
for _, user := range []domain.User{buyer, owner, offerBuyer, resaleBuyer, loser, prepayPayer} {
if _, _, err := stars.EnsureGrant(ctx, user.ID, 10000, now); err != nil {
t.Fatalf("grant stars to %d: %v", user.ID, err)
}
@ -48,12 +52,19 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
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")},
{Kind: domain.StarGiftCollectibleModel, Name: "Base Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+4, "model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "model-two"), Animation: collectibleTestAnimationPtr("model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+5, "pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+5, "pattern-two"), Animation: collectibleTestAnimationPtr("pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Night", BackdropID: 77, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Day", BackdropID: 78, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
},
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(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)
@ -93,32 +104,203 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
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,
PayerUserID: prepayPayer.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 {
if err != nil || prepaid.Saved.PrepaidUpgradeStars != 100 || prepaid.Saved.PrepaidUpgradeHash != "" || prepaid.Balance.Balance != 9900 {
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},
prepaySenderAction := prepaid.Send.SenderMessage.Media.ServiceAction.StarGift
prepayOwnerAction := prepaid.Send.RecipientMessage.Media.ServiceAction.StarGift
if prepaySenderAction == nil || prepayOwnerAction == nil ||
prepaySenderAction.GiftMsgID != 0 ||
prepayOwnerAction.GiftMsgID != purchased.Send.RecipientMessage.ID {
t.Fatalf("prepay gift_msg_id is not owner-only: sender=%+v owner=%+v purchase=%+v",
prepaySenderAction, prepayOwnerAction, purchased.Send)
}
prepaySenderDifference, err := NewUpdateEventStore(pool).ListAfter(ctx, prepayPayer.ID, prepaid.Send.SenderMessage.Pts-1, 1)
if err != nil || len(prepaySenderDifference) != 1 || prepaySenderDifference[0].Message.Media == nil ||
prepaySenderDifference[0].Message.Media.ServiceAction == nil ||
prepaySenderDifference[0].Message.Media.ServiceAction.StarGift == nil ||
prepaySenderDifference[0].Message.Media.ServiceAction.StarGift.GiftMsgID != 0 {
t.Fatalf("payer prepay difference leaked owner-only gift_msg_id: events=%+v err=%v", prepaySenderDifference, err)
}
prepayOwnerDifference, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, prepaid.Send.RecipientMessage.Pts-1, 1)
if err != nil || len(prepayOwnerDifference) != 1 || prepayOwnerDifference[0].Message.Media == nil ||
prepayOwnerDifference[0].Message.Media.ServiceAction == nil ||
prepayOwnerDifference[0].Message.Media.ServiceAction.StarGift == nil ||
prepayOwnerDifference[0].Message.Media.ServiceAction.StarGift.GiftMsgID != purchased.Send.RecipientMessage.ID {
t.Fatalf("owner prepay difference lost box-local gift_msg_id: events=%+v err=%v", prepayOwnerDifference, err)
}
var sharedPrepayMediaJSON string
if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p
JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id
WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, prepaid.Send.RecipientMessage.ID).Scan(&sharedPrepayMediaJSON); err != nil {
t.Fatalf("load shared prepay media: %v", err)
}
sharedPrepayMedia, err := decodeMessageMedia(sharedPrepayMediaJSON)
if err != nil || sharedPrepayMedia == nil || sharedPrepayMedia.ServiceAction == nil ||
sharedPrepayMedia.ServiceAction.StarGift == nil || sharedPrepayMedia.ServiceAction.StarGift.GiftMsgID != 0 {
t.Fatalf("shared prepay media retained account-local gift_msg_id: media=%+v err=%v", sharedPrepayMedia, err)
}
if byPrepay, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: prepaid.Send.RecipientMessage.ID}); err != nil || !found || byPrepay.ID != purchased.Saved.ID {
t.Fatalf("prepay owner message ref = %+v found=%v err=%v", byPrepay, found, err)
}
var ownerPrepayAlias, payerPrepayAlias int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, owner.ID, prepaid.Send.RecipientMessage.ID, purchased.Saved.ID).Scan(&ownerPrepayAlias); err != nil {
t.Fatalf("load owner prepay alias: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2`, prepayPayer.ID, prepaid.Send.SenderMessage.ID).Scan(&payerPrepayAlias); err != nil {
t.Fatalf("load payer prepay alias: %v", err)
}
if ownerPrepayAlias != 1 || payerPrepayAlias != 0 {
t.Fatalf("prepay aliases owner=%d payer=%d, want owner-only", ownerPrepayAlias, payerPrepayAlias)
}
upgradeReq := domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: prepaid.Send.RecipientMessage.ID},
RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "upgrade-" + suffix, Date: now + 2,
})
}
upgraded, err := upgrades.UpgradeStarGift(ctx, upgradeReq)
if err != nil {
t.Fatalf("upgrade prepaid gift: %v", err)
}
if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 ||
upgraded.Saved.CanCraftAt != now+2 ||
upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails {
t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique)
}
readinessTx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin craft readiness guard probe: %v", err)
}
if _, err = readinessTx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, upgraded.Saved.ID); err != nil {
_ = readinessTx.Rollback(ctx)
t.Fatalf("stage mismatched craft readiness: %v", err)
}
if _, err = readinessTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err == nil {
_ = readinessTx.Rollback(ctx)
t.Fatal("deferred guard accepted positive craft chance with zero readiness")
}
_ = readinessTx.Rollback(ctx)
chanceTx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin craft chance guard probe: %v", err)
}
if _, err = chanceTx.Exec(ctx, `UPDATE unique_star_gifts SET craft_chance_permille=0 WHERE id=$1`, upgraded.Unique.ID); err != nil {
_ = chanceTx.Rollback(ctx)
t.Fatalf("stage mismatched craft chance: %v", err)
}
if _, err = chanceTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err == nil {
_ = chanceTx.Rollback(ctx)
t.Fatal("deferred guard accepted positive readiness with zero craft chance")
}
_ = chanceTx.Rollback(ctx)
terminalTx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin atomic craft terminal guard probe: %v", err)
}
if _, err = terminalTx.Exec(ctx, `UPDATE unique_star_gifts SET craft_chance_permille=0 WHERE id=$1`, upgraded.Unique.ID); err != nil {
_ = terminalTx.Rollback(ctx)
t.Fatalf("stage terminal craft chance: %v", err)
}
if _, err = terminalTx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, upgraded.Saved.ID); err != nil {
_ = terminalTx.Rollback(ctx)
t.Fatalf("stage terminal craft readiness: %v", err)
}
if _, err = terminalTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err != nil {
_ = terminalTx.Rollback(ctx)
t.Fatalf("deferred guard rejected atomic craft terminal state: %v", err)
}
if err = terminalTx.Rollback(ctx); err != nil {
t.Fatalf("rollback atomic craft terminal guard probe: %v", err)
}
upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) ||
senderUpgradeAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
ownerSourceEdit := upgradedSourceEditForMessage(upgraded, owner.ID, purchased.Saved.MsgID)
ownerPrepayEdit := upgradedSourceEditForMessage(upgraded, owner.ID, prepaid.Send.RecipientMessage.ID)
payerPrepayEdit := upgradedSourceEditForMessage(upgraded, prepayPayer.ID, prepaid.Send.SenderMessage.ID)
if upgradeAction == nil || upgradeAction.SavedID != 0 || upgradeAction.Peer.Type != "" || upgradeAction.Peer.ID != 0 ||
upgradeAction.CanCraftAt != now+2 || senderUpgradeAction == nil || senderUpgradeAction.SavedID != 0 ||
senderUpgradeAction.CanCraftAt != now+2 ||
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)
}
if ownerPrepayEdit.Event.Pts <= ownerSourceEdit.Event.Pts || ownerPrepayEdit.Message.Media == nil ||
ownerPrepayEdit.Message.Media.ServiceAction == nil || ownerPrepayEdit.Message.Media.ServiceAction.StarGift == nil ||
ownerPrepayEdit.Message.Media.ServiceAction.StarGift.CanUpgrade ||
ownerPrepayEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Send.RecipientMessage.ID {
t.Fatalf("owner prepay card did not converge with upgrade: %+v", ownerPrepayEdit)
}
if payerPrepayEdit.Event.Pts <= prepaid.Send.SenderMessage.Pts || payerPrepayEdit.Message.Media == nil ||
payerPrepayEdit.Message.Media.ServiceAction == nil || payerPrepayEdit.Message.Media.ServiceAction.StarGift == nil ||
payerPrepayEdit.Message.Media.ServiceAction.StarGift.CanUpgrade ||
payerPrepayEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != 0 {
t.Fatalf("third-party payer prepay card retained an owner action/link: %+v", payerPrepayEdit)
}
ownerUpgradeDifference, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, upgraded.Send.RecipientMessage.Pts-1, 3)
if err != nil || len(ownerUpgradeDifference) != 3 ||
ownerUpgradeDifference[0].Type != domain.UpdateEventNewMessage ||
ownerUpgradeDifference[1].Type != domain.UpdateEventEditMessage || ownerUpgradeDifference[1].Message.ID != purchased.Saved.MsgID ||
ownerUpgradeDifference[2].Type != domain.UpdateEventEditMessage || ownerUpgradeDifference[2].Message.ID != prepaid.Send.RecipientMessage.ID {
t.Fatalf("owner prepaid upgrade difference = %+v err=%v", ownerUpgradeDifference, err)
}
payerUpgradeDifference, err := NewUpdateEventStore(pool).ListAfter(ctx, prepayPayer.ID, prepaid.Send.SenderMessage.Pts, 1)
if err != nil || len(payerUpgradeDifference) != 1 || payerUpgradeDifference[0].Type != domain.UpdateEventEditMessage ||
payerUpgradeDifference[0].Message.ID != prepaid.Send.SenderMessage.ID {
t.Fatalf("payer prepaid upgrade difference = %+v err=%v", payerUpgradeDifference, err)
}
replayedUpgrade, err := upgrades.UpgradeStarGift(ctx, upgradeReq)
if err != nil || !replayedUpgrade.Duplicate || replayedUpgrade.Unique.ID != upgraded.Unique.ID ||
upgradedSourceEditForMessage(replayedUpgrade, owner.ID, purchased.Saved.MsgID).Event.Pts != ownerSourceEdit.Event.Pts ||
upgradedSourceEditForMessage(replayedUpgrade, owner.ID, prepaid.Send.RecipientMessage.ID).Event.Pts != ownerPrepayEdit.Event.Pts {
t.Fatalf("replay prepaid upgrade from notification = %+v err=%v", replayedUpgrade, err)
}
var sharedUpgradeSourceMediaJSON string
if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p
JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id
WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, purchased.Saved.MsgID).Scan(&sharedUpgradeSourceMediaJSON); err != nil {
t.Fatalf("load shared upgraded source media: %v", err)
}
sharedUpgradeSourceMedia, err := decodeMessageMedia(sharedUpgradeSourceMediaJSON)
if err != nil || sharedUpgradeSourceMedia == nil || sharedUpgradeSourceMedia.ServiceAction == nil ||
sharedUpgradeSourceMedia.ServiceAction.StarGift == nil ||
sharedUpgradeSourceMedia.ServiceAction.StarGift.UpgradeMsgID != 0 ||
sharedUpgradeSourceMedia.ServiceAction.StarGift.GiftMsgID != 0 {
t.Fatalf("shared upgraded source media retained account-local message id: media=%+v err=%v", sharedUpgradeSourceMedia, err)
}
var sharedUpgradedPrepayMediaJSON string
if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p
JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id
WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, prepaid.Send.RecipientMessage.ID).Scan(&sharedUpgradedPrepayMediaJSON); err != nil {
t.Fatalf("load shared upgraded prepay media: %v", err)
}
sharedUpgradedPrepayMedia, err := decodeMessageMedia(sharedUpgradedPrepayMediaJSON)
if err != nil || sharedUpgradedPrepayMedia == nil || sharedUpgradedPrepayMedia.ServiceAction == nil ||
sharedUpgradedPrepayMedia.ServiceAction.StarGift == nil ||
sharedUpgradedPrepayMedia.ServiceAction.StarGift.CanUpgrade ||
sharedUpgradedPrepayMedia.ServiceAction.StarGift.PrepaidUpgradeHash != "" ||
sharedUpgradedPrepayMedia.ServiceAction.StarGift.UpgradeMsgID != 0 ||
sharedUpgradedPrepayMedia.ServiceAction.StarGift.GiftMsgID != 0 {
t.Fatalf("shared upgraded prepay media retained an action or account-local id: media=%+v err=%v", sharedUpgradedPrepayMedia, err)
}
verifyPrepaidMessageRefMigration(t, ctx, pool, purchased.Saved.ID, owner.ID, prepayPayer.ID,
prepaid.Send.RecipientMessage.ID, prepaid.Send.SenderMessage.ID, upgraded.Send.RecipientMessage.ID)
var sharedUpgradeMediaJSON string
if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p
JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id
WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, upgraded.Send.RecipientMessage.ID).Scan(&sharedUpgradeMediaJSON); err != nil {
t.Fatalf("load shared upgrade media: %v", err)
}
sharedUpgradeMedia, err := decodeMessageMedia(sharedUpgradeMediaJSON)
if err != nil || sharedUpgradeMedia == nil || sharedUpgradeMedia.ServiceAction == nil ||
sharedUpgradeMedia.ServiceAction.StarGiftUnique == nil || sharedUpgradeMedia.ServiceAction.StarGiftUnique.SavedID != 0 {
t.Fatalf("shared upgrade media retained account-local saved_id: media=%+v err=%v", sharedUpgradeMedia, err)
}
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,
@ -248,6 +430,47 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 {
t.Fatalf("paid transfer = %+v err %v", transferred, err)
}
const historicalOwnerMessageID = 2_147_483_000
if _, err := pool.Exec(ctx, `INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
VALUES($1,$2,$3)`, resaleBuyer.ID, historicalOwnerMessageID, transferred.Saved.ID); err != nil {
t.Fatalf("insert historical old-owner message ref: %v", err)
}
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{{
Owner: ownerPeer, MsgID: historicalOwnerMessageID,
}}); !errors.Is(err, domain.ErrStarGiftNotFound) {
t.Fatalf("current owner resolved another owner's historical message ref: %v", err)
}
var retiredSourceMediaJSON string
var retiredSourcePTS int
if err := pool.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, resaleBuyer.ID, resold.Saved.MsgID).
Scan(&retiredSourceMediaJSON, &retiredSourcePTS); err != nil {
t.Fatalf("load retired transfer source projection: %v", err)
}
retiredSourceMedia, err := decodeMessageMedia(retiredSourceMediaJSON)
if err != nil || retiredSourceMedia == nil || retiredSourceMedia.ServiceAction == nil ||
retiredSourceMedia.ServiceAction.StarGiftUnique == nil {
t.Fatalf("decode retired transfer source projection: media=%+v err=%v", retiredSourceMedia, err)
}
retiredSourceAction := retiredSourceMedia.ServiceAction.StarGiftUnique
if retiredSourceAction.Gift.Owner != ownerPeer || retiredSourceAction.Gift.CraftChancePermille != 0 ||
!retiredSourceAction.Transferred || retiredSourceAction.Saved || retiredSourceAction.CanCraftAt != 0 ||
retiredSourceAction.CanExportAt != 0 || retiredSourceAction.TransferStars != 0 ||
retiredSourceAction.CanTransferAt != 0 || retiredSourceAction.CanResellAt != 0 ||
retiredSourceAction.DropOriginalDetailsStars != 0 || retiredSourceAction.ResaleAmount != nil {
t.Fatalf("retired transfer source remained actionable: %+v", retiredSourceAction)
}
var retiredEventCount, retiredOutboxCount int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, resaleBuyer.ID, retiredSourcePTS, resold.Saved.MsgID).
Scan(&retiredEventCount); err != nil || retiredEventCount != 1 {
t.Fatalf("retired transfer source event count=%d err=%v", retiredEventCount, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, resaleBuyer.ID, retiredSourcePTS).
Scan(&retiredOutboxCount); err != nil || retiredOutboxCount != 1 {
t.Fatalf("retired transfer source outbox count=%d err=%v", retiredOutboxCount, err)
}
clearedUser, found, err := users.ByID(ctx, resaleBuyer.ID)
if err != nil || !found || !clearedUser.EmojiStatus().Empty() {
t.Fatalf("transferred collectible status was not cleared: user=%+v found=%v err=%v", clearedUser, found, err)
@ -322,13 +545,13 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
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)
}); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("duplicate upgrade output/slug identities err = %v", 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)
}); err != nil || !found || saved.ID != secondUpgrade.Saved.ID {
t.Fatalf("upgrade output message id failed to resolve: saved=%+v found=%v err=%v", saved, found, err)
}
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.MsgID},
@ -347,6 +570,15 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
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)
}
craftOutputAction := crafted.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
if craftOutputAction == nil || craftOutputAction.Peer.Type != "" || craftOutputAction.Peer.ID != 0 || craftOutputAction.SavedID != 0 || !craftOutputAction.Craft {
t.Fatalf("craft output action leaked channel identity: %+v", craftOutputAction)
}
if byOutput, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{
Owner: ownerPeer, MsgID: crafted.Send.SenderMessage.ID,
}); err != nil || !found || byOutput.ID != transferred.Saved.ID || byOutput.UniqueGiftID != crafted.Gift.ID {
t.Fatalf("craft output message ref = %+v found %v err %v", byOutput, found, err)
}
craftedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, transferred.Unique.ID)
craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit)
burnedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, secondUpgrade.Unique.ID)
@ -359,6 +591,21 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
burnedInputAction.Saved || burnedInputAction.CanCraftAt != 0 {
t.Fatalf("burned input message projection = %+v", burnedInputAction)
}
for _, edit := range []domain.EditedMessageForUser{craftedInputEdit, burnedInputEdit} {
var sharedCraftInputMediaJSON string
if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p
JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id
WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, edit.Message.ID).Scan(&sharedCraftInputMediaJSON); err != nil {
t.Fatalf("load shared craft input media for box %d: %v", edit.Message.ID, err)
}
sharedCraftInputMedia, err := decodeMessageMedia(sharedCraftInputMediaJSON)
if err != nil || sharedCraftInputMedia == nil || sharedCraftInputMedia.ServiceAction == nil ||
sharedCraftInputMedia.ServiceAction.StarGiftUnique == nil ||
sharedCraftInputMedia.ServiceAction.StarGiftUnique.SavedID != 0 {
t.Fatalf("shared craft input retained account-local saved_id for box %d: media=%+v err=%v",
edit.Message.ID, sharedCraftInputMedia, err)
}
}
craftReq := domain.StarGiftCraftRequest{UserID: owner.ID,
Refs: []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
@ -372,6 +619,40 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
craftedSourceEditForUserAndGift(craftedReplay, owner.ID, secondUpgrade.Unique.ID).Event.Pts != burnedInputEdit.Event.Pts {
t.Fatalf("craft success replay = %+v err %v", craftedReplay, err)
}
craftOutputRef := domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: crafted.Send.SenderMessage.ID}
if changed, err := gifts.SetUnsaved(ctx, craftOutputRef, true); err != nil || !changed {
t.Fatalf("hide crafted output before replay: changed=%v err=%v", changed, err)
}
hiddenReplay, err := lifecycle.CraftStarGift(ctx, craftReq)
if err != nil || !hiddenReplay.Duplicate || hiddenReplay.Send.SenderMessage.ID != crafted.Send.SenderMessage.ID {
t.Fatalf("craft replay after hide = %+v err %v", hiddenReplay, err)
}
if changed, err := gifts.SetUnsaved(ctx, craftOutputRef, false); err != nil || !changed {
t.Fatalf("restore crafted output before listing replay: changed=%v err=%v", changed, err)
}
listedCraftOutput, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID,
Ref: craftOutputRef, Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, Date: now + 148,
})
if err != nil || listedCraftOutput.ResellAmount == nil || listedCraftOutput.ResellAmount.Amount != 125 {
t.Fatalf("list crafted output before replay = %+v err %v", listedCraftOutput, err)
}
listedReplay, err := lifecycle.CraftStarGift(ctx, craftReq)
if err != nil || !listedReplay.Duplicate || listedReplay.Gift == nil || listedReplay.Gift.ResellAmount != nil ||
listedReplay.Send.SenderMessage.ID != crafted.Send.SenderMessage.ID {
t.Fatalf("craft replay after listing did not use frozen output = %+v err %v", listedReplay, err)
}
if _, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID,
Ref: craftOutputRef, Date: now + 149,
}); err != nil {
t.Fatalf("remove crafted output listing: %v", err)
}
var outputReceiptMedia string
var outputReceiptFingerprint []byte
if err := pool.QueryRow(ctx, `SELECT output_media::text,output_fingerprint FROM star_gift_craft_commands
WHERE user_id=$1 AND command_key=$2`, owner.ID, craftReq.CommandKey).Scan(&outputReceiptMedia, &outputReceiptFingerprint); err != nil ||
outputReceiptMedia == "" || len(outputReceiptFingerprint) != 32 {
t.Fatalf("craft immutable output receipt: media=%q fingerprint=%d err=%v", outputReceiptMedia, len(outputReceiptFingerprint), 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 {
@ -413,7 +694,7 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
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}},
Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.UpgradeMsgID}},
CommandKey: "craft-fail-" + suffix, Date: now + 150,
}
failedCraft, err := failingLifecycle.CraftStarGift(ctx, failureReq)
@ -452,6 +733,11 @@ can_resell_at,drop_original_details_stars,can_craft_at FROM peer_star_gifts WHER
craftedSourceEditForUserAndGift(failedReplay, owner.ID, thirdUpgrade.Unique.ID).Event.Pts != failedInputEdit.Event.Pts {
t.Fatalf("craft failure replay = %+v err %v", failedReplay, err)
}
wrongAliasReplay := failureReq
wrongAliasReplay.Refs = []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID}}
if _, err := failingLifecycle.CraftStarGift(ctx, wrongAliasReplay); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
t.Fatalf("craft replay accepted another aggregate alias: %v", err)
}
invalidRetry := failureReq
invalidRetry.CommandKey = "craft-fail-new-command-" + suffix
if _, err := failingLifecycle.CraftStarGift(ctx, invalidRetry); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
@ -555,13 +841,24 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
}
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: collectibleTestPatternDocumentPtr(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}},
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")},
{Kind: domain.StarGiftCollectibleModel, Name: "Channel Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
Document: collectibleTestDocumentPtr(baseDocumentID+2, "channel-crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-crafted"), Animation: collectibleTestAnimationPtr("channel-crafted.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+4, "channel-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "channel-model-two"), Animation: collectibleTestAnimationPtr("channel-model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+5, "channel-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+5, "channel-pattern-two"), Animation: collectibleTestAnimationPtr("channel-pattern-two.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},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Channel Backdrop Two", BackdropID: 89, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
},
Actor: "integration", CommandID: "channel-gift-pool-" + suffix,
}); err != nil {
t.Fatalf("publish channel gift pool: %v", err)
@ -687,7 +984,9 @@ WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel
}
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 {
action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 ||
action.CanCraftAt != 0 || action.Gift.CraftChancePermille != 500 ||
upgraded.Saved.CanCraftAt != now+5 || upgraded.Unique.CraftChancePermille != 500 {
t.Fatalf("channel upgrade service action = %+v", action)
}
var ptsAfterUpgrade int
@ -731,7 +1030,9 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe
}
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 {
resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 ||
resold.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
resold.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
t.Fatalf("channel-to-channel local TON resale = %+v err %v", resold, err)
}
var channelTON, channelTONTxns, targetResaleLogs, commission int64
@ -763,6 +1064,36 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe
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)
}
toUser, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{
ActorUserID: actor.ID,
Ref: domain.SavedStarGiftRef{
Owner: targetChannelPeer, SavedID: resold.Saved.SavedID,
},
To: domain.Peer{Type: domain.PeerTypeUser, ID: actor.ID}, ChargeStars: resold.Saved.TransferStars,
CommandKey: "channel-craft-entitlement-to-user-" + suffix, Date: now + 8,
})
if err != nil || toUser.Saved.Owner.Type != domain.PeerTypeUser || toUser.Saved.Owner.ID != actor.ID ||
toUser.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
toUser.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
t.Fatalf("channel-to-user Craft entitlement transfer = %+v err %v", toUser, err)
}
toUserAction := toUser.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
if toUserAction == nil || toUserAction.CanCraftAt != upgraded.Saved.CanCraftAt {
t.Fatalf("channel-to-user action did not restore Craft readiness: %+v", toUserAction)
}
backToChannel, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{
ActorUserID: actor.ID,
Ref: domain.SavedStarGiftRef{
Owner: toUser.Saved.Owner, MsgID: toUser.Saved.MsgID,
},
To: channelPeer, ChargeStars: toUser.Saved.TransferStars,
CommandKey: "user-craft-entitlement-to-channel-" + suffix, Date: now + 9,
})
if err != nil || backToChannel.Saved.Owner != channelPeer ||
backToChannel.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
backToChannel.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
t.Fatalf("user-to-channel Craft entitlement transfer = %+v err %v", backToChannel, 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 {
@ -816,6 +1147,125 @@ WHERE channel_id=$1 AND message::text LIKE '%auction_acquired%'`, created.Channe
}
}
func TestStarGiftCraftFailureConsumesThreeInputsPostgres(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, "+1883"+suffix+"01", "CraftBuyer", "")
owner := createTestUser(t, ctx, users, "+1883"+suffix+"02", "CraftOwner", "")
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
stars := NewStarsStore(pool)
for _, userID := range []int64{buyer.ID, owner.ID} {
if _, _, err := stars.EnsureGrant(ctx, userID, 10000, now); err != nil {
t.Fatalf("grant craft stars to %d: %v", userID, err)
}
}
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Three Input Craft " + suffix, Stars: 50, ConvertStars: 20, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "three-input.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "three-input"), Animation: collectibleTestAnimation("three-input.tgs"),
Actor: "integration", CommandID: "three-input-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create three-input catalog: %v", err)
}
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "three-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "base.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "base"), Animation: collectibleTestAnimationPtr("base.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")},
{Kind: domain.StarGiftCollectibleModel, Name: "Base Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+4, "base-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "base-two"), Animation: collectibleTestAnimationPtr("base-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+5, "pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+5, "pattern-two"), Animation: collectibleTestAnimationPtr("pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 88, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 89, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
},
Actor: "integration", CommandID: "three-input-pool-" + suffix,
}); err != nil {
t.Fatalf("publish three-input collectible: %v", err)
}
messages := NewMessageStore(pool)
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000,
WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil }))
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 250,
}))
refs := make([]domain.SavedStarGiftRef, 0, 3)
uniqueIDs := make([]int64, 0, 3)
for i := 0; i < 3; i++ {
purchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{
BuyerUserID: buyer.ID, To: ownerPeer, GiftID: entry.Gift.ID, IncludeUpgrade: true,
CommandKey: fmt.Sprintf("three-input-purchase-%s-%d", suffix, i), Date: now + i,
})
purchased, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
if err != nil {
t.Fatalf("purchase three-input gift %d: %v", i, err)
}
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID,
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID}, RequirePrepaid: true,
CommandKey: fmt.Sprintf("three-input-upgrade-%s-%d", suffix, i), Date: now + 10 + i,
})
if err != nil {
t.Fatalf("upgrade three-input gift %d: %v", i, err)
}
refs = append(refs, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: upgraded.Saved.UpgradeMsgID})
uniqueIDs = append(uniqueIDs, upgraded.Unique.ID)
}
req := domain.StarGiftCraftRequest{UserID: owner.ID, Refs: refs,
CommandKey: "three-input-craft-fail-" + suffix, Date: now + 20}
failed, err := lifecycle.CraftStarGift(ctx, req)
if err != nil || failed.Success || failed.Chance != 750 || failed.Gift != nil {
t.Fatalf("three-input craft failure = %+v err=%v", failed, err)
}
for _, uniqueID := range uniqueIDs {
edit := craftedSourceEditForUserAndGift(failed, owner.ID, uniqueID)
action := starGiftUniqueActionFromEdit(edit)
if edit.Event.Pts <= 0 || action == nil || !action.Gift.Burned || action.Gift.CraftChancePermille != 0 ||
action.Saved || action.CanCraftAt != 0 {
t.Fatalf("three-input terminal edit for %d = %+v", uniqueID, edit)
}
var burned bool
var status string
if err := pool.QueryRow(ctx, `SELECT u.burned,p.lifecycle_status
FROM unique_star_gifts u JOIN peer_star_gifts p ON p.unique_gift_id=u.id WHERE u.id=$1`, uniqueID).
Scan(&burned, &status); err != nil || !burned || status != "burned" {
t.Fatalf("three-input terminal aggregate %d burned=%v status=%q err=%v", uniqueID, burned, status, err)
}
}
var sourcePTS []int32
var outputMedia, outputFingerprint []byte
if err := pool.QueryRow(ctx, `SELECT source_edit_pts,output_media,output_fingerprint
FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`, owner.ID, req.CommandKey).
Scan(&sourcePTS, &outputMedia, &outputFingerprint); err != nil || len(sourcePTS) != 3 ||
len(outputMedia) != 0 || len(outputFingerprint) != 0 {
t.Fatalf("three-input failure receipt pts=%v media=%d fingerprint=%d err=%v", sourcePTS, len(outputMedia), len(outputFingerprint), err)
}
replay, err := lifecycle.CraftStarGift(ctx, req)
if err != nil || !replay.Duplicate || replay.Success || replay.Chance != 750 {
t.Fatalf("three-input failure replay = %+v err=%v", replay, err)
}
for i, uniqueID := range uniqueIDs {
if edit := craftedSourceEditForUserAndGift(replay, owner.ID, uniqueID); edit.Event.Pts != int(sourcePTS[i]) {
t.Fatalf("three-input replay pts for %d = %d want %d", uniqueID, edit.Event.Pts, sourcePTS[i])
}
}
}
func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *StarGiftLifecycleStore,
req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest {
t.Helper()
@ -844,6 +1294,100 @@ func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *St
return req
}
func upgradedSourceEditForMessage(result domain.StarGiftUpgradeResult, userID int64, messageID int) domain.EditedMessageForUser {
for _, edit := range result.SourceEdits {
if edit.UserID == userID && edit.Message.ID == messageID {
return edit
}
}
return domain.EditedMessageForUser{UserID: userID}
}
func verifyPrepaidMessageRefMigration(
t *testing.T,
ctx context.Context,
pool *pgxpool.Pool,
savedGiftID int64,
ownerUserID int64,
payerUserID int64,
ownerPrepayMessageID int,
payerPrepayMessageID int,
ownerUpgradeMessageID int,
) {
t.Helper()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin prepaid message migration probe: %v", err)
}
defer func() { _ = tx.Rollback(context.Background()) }()
var messageSenderID, privateMessageID int64
if err := tx.QueryRow(ctx, `SELECT message_sender_id,private_message_id FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, ownerUserID, ownerPrepayMessageID).
Scan(&messageSenderID, &privateMessageID); err != nil {
t.Fatalf("load prepaid message root for migration probe: %v", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, ownerUserID, ownerPrepayMessageID, savedGiftID); err != nil {
t.Fatalf("remove prepaid alias for migration probe: %v", err)
}
if _, err := tx.Exec(ctx, `UPDATE message_boxes
SET media=jsonb_set(media #- '{service_action,star_gift,upgrade_msg_id}',
'{service_action,star_gift,can_upgrade}','true'::jsonb,true)
WHERE message_sender_id=$1 AND private_message_id=$2 AND NOT deleted`, messageSenderID, privateMessageID); err != nil {
t.Fatalf("restore stale prepaid message boxes for migration probe: %v", err)
}
if _, err := tx.Exec(ctx, `UPDATE private_messages
SET media=jsonb_set(media #- '{service_action,star_gift,upgrade_msg_id}',
'{service_action,star_gift,can_upgrade}','true'::jsonb,true)
WHERE sender_user_id=$1 AND id=$2`, messageSenderID, privateMessageID); err != nil {
t.Fatalf("restore stale shared prepaid message for migration probe: %v", err)
}
migrationSQL, err := deploy.Migrations.ReadFile("migrations/0135_star_gift_prepaid_message_refs.up.sql")
if err != nil {
t.Fatalf("read prepaid message migration: %v", err)
}
if _, err := tx.Exec(ctx, string(migrationSQL)); err != nil {
t.Fatalf("apply prepaid message migration probe: %v", err)
}
var aliasCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, ownerUserID, ownerPrepayMessageID, savedGiftID).Scan(&aliasCount); err != nil || aliasCount != 1 {
t.Fatalf("migrated prepaid alias count=%d err=%v", aliasCount, err)
}
assertMigratedAction := func(userID int64, messageID int, wantUpgradeMessageID int) {
t.Helper()
var mediaJSON string
var pts int
if err := tx.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, userID, messageID).Scan(&mediaJSON, &pts); err != nil {
t.Fatalf("load migrated prepaid box %d/%d: %v", userID, messageID, err)
}
media, err := decodeMessageMedia(mediaJSON)
if err != nil || media == nil || media.ServiceAction == nil || media.ServiceAction.StarGift == nil ||
media.ServiceAction.StarGift.CanUpgrade || media.ServiceAction.StarGift.PrepaidUpgradeHash != "" ||
media.ServiceAction.StarGift.UpgradeMsgID != wantUpgradeMessageID {
t.Fatalf("migrated prepaid box %d/%d = %+v err=%v", userID, messageID, media, err)
}
var eventCount, outboxCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, userID, pts, messageID).Scan(&eventCount); err != nil {
t.Fatalf("load migrated prepaid event: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, userID, pts).Scan(&outboxCount); err != nil {
t.Fatalf("load migrated prepaid outbox: %v", err)
}
if eventCount != 1 || outboxCount != 1 {
t.Fatalf("migrated prepaid event/outbox counts=%d/%d", eventCount, outboxCount)
}
}
assertMigratedAction(ownerUserID, ownerPrepayMessageID, ownerUpgradeMessageID)
assertMigratedAction(payerUserID, payerPrepayMessageID, 0)
}
func craftedSourceEditForUserAndGift(result domain.StarGiftCraftResult, userID, uniqueGiftID int64) domain.EditedMessageForUser {
for _, edit := range result.SourceEdits {
if edit.UserID != userID {

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 20260714003082 {
t.Fatalf("migration status = %+v, want clean version 20260714003082", status)
if status.Dirty || status.Empty || status.Version != 20260714003097 {
t.Fatalf("migration status = %+v, want clean version 20260714003097", status)
}
}

View file

@ -0,0 +1,196 @@
package postgres
import (
"context"
"errors"
"fmt"
"sort"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// retireUserStarGiftMessagesTx closes every user-scoped unique-gift action
// emitted for the source ownership epoch. Ownership moves and terminal export
// must not leave an older chat card with Craft/transfer/resale capabilities.
// The aggregate mutation and all message edits share one transaction and each
// visible box receives its own durable pts/event/outbox entry.
func (s *StarGiftLifecycleStore) retireUserStarGiftMessagesTx(
ctx context.Context,
tx pgx.Tx,
source domain.SavedStarGift,
current domain.UniqueStarGift,
date int,
) ([]domain.EditedMessageForUser, error) {
if s == nil || s.messages == nil || source.Owner.Type != domain.PeerTypeUser || source.Owner.ID <= 0 ||
source.ID <= 0 || source.UniqueGiftID <= 0 || current.ID != source.UniqueGiftID || date <= 0 {
return nil, domain.ErrStarGiftTransferUnavailable
}
messageIDs := map[int]struct{}{}
if source.MsgID > 0 {
messageIDs[source.MsgID] = struct{}{}
}
if source.UpgradeMsgID > 0 {
messageIDs[source.UpgradeMsgID] = struct{}{}
}
rows, err := tx.Query(ctx, `
SELECT msg_id FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND saved_gift_id=$2
ORDER BY msg_id`, source.Owner.ID, source.ID)
if err != nil {
return nil, fmt.Errorf("list star gift message projections: %w", err)
}
for rows.Next() {
var msgID int
if err := rows.Scan(&msgID); err != nil {
rows.Close()
return nil, fmt.Errorf("scan star gift message projection: %w", err)
}
if msgID > 0 {
messageIDs[msgID] = struct{}{}
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("iterate star gift message projections: %w", err)
}
rows.Close()
ids := make([]int, 0, len(messageIDs))
for msgID := range messageIDs {
ids = append(ids, msgID)
}
sort.Ints(ids)
q := sqlcgen.New(tx)
edits := make([]domain.EditedMessageForUser, 0, len(ids)*2)
seenPrivateMessages := make(map[string]struct{}, len(ids))
for _, msgID := range ids {
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`, source.Owner.ID, msgID).Scan(&peerType, &peerID)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return nil, fmt.Errorf("lock star gift message projection: %w", err)
}
if peerType != string(domain.PeerTypeUser) || peerID <= 0 {
return nil, fmt.Errorf("star gift message projection %d is not private", msgID)
}
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
OwnerUserID: source.Owner.ID, BoxID: int32(msgID), PeerType: peerType, PeerID: peerID,
})
if err != nil {
return nil, fmt.Errorf("load star gift message projection: %w", err)
}
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(source.Owner.ID, peerID),
MessageSenderID: target.MessageSenderID, PrivateMessageID: target.PrivateMessageID,
})
if err != nil {
return nil, fmt.Errorf("list visible star gift message projections: %w", err)
}
var privateMediaJSON []byte
matched := false
for _, box := range boxes {
media, err := decodeMessageMedia(box.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode star gift message projection: %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 != current.ID {
continue
}
matched = true
action := media.ServiceAction.StarGiftUnique
retiredGift := current
retiredGift.CraftChancePermille = 0
retiredGift.ResellAmount = nil
action.Gift = retiredGift
action.Peer = domain.Peer{}
action.SavedID = 0
action.Saved = false
if validLifecyclePeer(current.Owner) && current.Owner != source.Owner {
action.Transferred = true
}
action.CanExportAt = 0
action.TransferStars = 0
action.ResaleAmount = nil
action.CanTransferAt = 0
action.CanResellAt = 0
action.DropOriginalDetailsStars = 0
action.CanCraftAt = 0
mediaJSON, err := encodeMessageMedia(media)
if err != nil {
return nil, fmt.Errorf("encode retired star gift projection: %w", err)
}
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
if err != nil {
return nil, fmt.Errorf("allocate retired star gift 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 retired star gift projection: %w", err)
}
if tag.RowsAffected() != 1 {
return nil, fmt.Errorf("update retired star gift projection 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: date, Message: msg}
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
return nil, fmt.Errorf("append retired star gift edit event: %w", err)
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage),
ExcludeAuthKeyID: 0, ExcludeSessionID: 0,
}); err != nil {
return nil, fmt.Errorf("enqueue retired star gift 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 !matched {
continue
}
if len(privateMediaJSON) == 0 {
return nil, fmt.Errorf("retired star gift projection missing shared media")
}
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 retired star gift private media: %w", err)
}
}
return edits, nil
}

View file

@ -0,0 +1,115 @@
package postgres
import (
"testing"
"telesrv/internal/domain"
)
func TestTransferUniqueActionSavedIDNamespace(t *testing.T) {
saved := domain.SavedStarGift{SavedID: 42, CanCraftAt: 1_780_000_123}
unique := domain.UniqueStarGift{ID: 7}
user := domain.Peer{Type: domain.PeerTypeUser, ID: 100}
channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 200}
if action := transferUniqueAction(unique, 1, user, saved); action.SavedID != 0 || action.CanCraftAt != saved.CanCraftAt {
t.Fatalf("user transfer action leaked channel saved_id: %+v", action)
}
if action := transferUniqueAction(unique, 1, channel, saved); action.SavedID != saved.SavedID || action.CanCraftAt != 0 {
t.Fatalf("channel transfer action lost channel saved_id: %+v", action)
}
}
func TestStarGiftCraftReadyAt(t *testing.T) {
const date = 1_780_000_000
if got := starGiftCraftReadyAt(date, 0); got != date {
t.Fatalf("zero-delay craft ready_at = %d, want %d", got, date)
}
if got := starGiftCraftReadyAt(date, 60); got != date+60 {
t.Fatalf("delayed craft ready_at = %d, want %d", got, date+60)
}
if got := starGiftCraftReadyAt(0, 0); got != 0 {
t.Fatalf("invalid-date craft ready_at = %d, want 0", got)
}
if got := starGiftCraftReadyAt(1<<31-10, 60); got != 1<<31-1 {
t.Fatalf("overflow craft ready_at = %d, want max int32", got)
}
if got := starGiftCraftReadyAt(1<<31+10, 0); got != 1<<31-1 {
t.Fatalf("oversized-date craft ready_at = %d, want max int32", got)
}
}
func TestEncodeSharedPrivateStarGiftMediaOmitsUserBoxLocalRefs(t *testing.T) {
ordinary := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
PeerUserID: 9,
SavedID: 10, GiftMsgID: 11, UpgradeMsgID: 12,
},
},
}
encoded, err := encodeSharedPrivateStarGiftMedia(ordinary)
if err != nil {
t.Fatalf("encode ordinary shared projection: %v", err)
}
sharedOrdinary, err := decodeMessageMedia(string(encoded))
if err != nil {
t.Fatalf("decode ordinary shared projection: %v", err)
}
ordinaryAction := sharedOrdinary.ServiceAction.StarGift
if ordinaryAction.SavedID != 0 || ordinaryAction.GiftMsgID != 0 || ordinaryAction.UpgradeMsgID != 0 {
t.Fatalf("ordinary shared projection retained box-local refs: %+v", ordinaryAction)
}
if original := ordinary.ServiceAction.StarGift; original.SavedID != 10 || original.GiftMsgID != 11 || original.UpgradeMsgID != 12 {
t.Fatalf("ordinary source projection was mutated: %+v", original)
}
unique := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 9}, SavedID: 13,
},
},
}
encoded, err = encodeSharedPrivateStarGiftMedia(unique)
if err != nil {
t.Fatalf("encode unique shared projection: %v", err)
}
sharedUnique, err := decodeMessageMedia(string(encoded))
if err != nil {
t.Fatalf("decode unique shared projection: %v", err)
}
if action := sharedUnique.ServiceAction.StarGiftUnique; action.SavedID != 0 {
t.Fatalf("unique shared projection retained user saved_id: %+v", action)
}
if unique.ServiceAction.StarGiftUnique.SavedID != 13 {
t.Fatalf("unique source projection was mutated: %+v", unique.ServiceAction.StarGiftUnique)
}
}
func TestEncodeSharedPrivateStarGiftMediaPreservesChannelSavedID(t *testing.T) {
media := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9}, SavedID: 14,
},
},
}
encoded, err := encodeSharedPrivateStarGiftMedia(media)
if err != nil {
t.Fatalf("encode channel shared projection: %v", err)
}
shared, err := decodeMessageMedia(string(encoded))
if err != nil {
t.Fatalf("decode channel shared projection: %v", err)
}
if action := shared.ServiceAction.StarGiftUnique; action.SavedID != 14 {
t.Fatalf("channel shared projection lost saved_id: %+v", action)
}
}

View file

@ -22,7 +22,7 @@ func TestOfficialStarGiftBundleIsAtomicPostgres(t *testing.T) {
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.BackdropID = int(id)
value.CenterColor, value.EdgeColor, value.PatternColor, value.TextColor = 1, 2, 3, 4
return value
}
@ -46,10 +46,19 @@ func TestOfficialStarGiftBundleIsAtomicPostgres(t *testing.T) {
},
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,
Models: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectibleModel, baseID+1, "model"),
attribute(domain.StarGiftCollectibleModel, baseID+3, "model-two"),
},
Patterns: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectiblePattern, baseID+2, "pattern"),
attribute(domain.StarGiftCollectiblePattern, baseID+4, "pattern-two"),
},
Backdrops: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop"),
attribute(domain.StarGiftCollectibleBackdrop, 1, "backdrop-two"),
},
Actor: "integration", CommandID: "official-pool-" + suffix,
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA,
},
}
@ -81,9 +90,15 @@ FROM star_gift_catalog_revisions WHERE id=$1`, result.Catalog.Gift.RevisionID).S
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,
Patterns: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectiblePattern, baseID+103, "pattern"),
attribute(domain.StarGiftCollectiblePattern, baseID+104, "pattern-two"),
},
Backdrops: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop"),
attribute(domain.StarGiftCollectibleBackdrop, 1, "backdrop-two"),
},
Actor: "integration", CommandID: "rollback-pool-" + suffix,
}
if _, err := store.CreateCatalogBundle(ctx, failing); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("failing bundle err=%v", err)

View file

@ -0,0 +1,131 @@
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
// projectPrivateStarGiftSourceRef exposes a user-owned gift's stable source
// message identity only in the gift owner's message-box projection. Telegram
// defines gift_msg_id as receiver-only. A non-owner counterpart box id is not
// a valid substitute: it could resolve to an unrelated gift owned by that
// viewer. User unique actions do not use channel-only peer/saved_id fields;
// their owner-scoped message ids are registered separately at write time.
func projectPrivateStarGiftSourceRef(
_ context.Context,
_ pgx.Tx,
req *domain.SendPrivateTextRequest,
sourceOwnerUserID int64,
sourceOwnerBoxID int,
) (privateSendMediaProjection, error) {
if req == nil || req.Media == nil || sourceOwnerUserID <= 0 || sourceOwnerBoxID <= 0 ||
(sourceOwnerUserID != req.SenderUserID && sourceOwnerUserID != req.RecipientUserID) {
return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: invalid scope")
}
shared, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
sender, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
recipient, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
switch {
case privateStarGiftAction(shared) != nil:
sharedAction := privateStarGiftAction(shared)
senderAction := privateStarGiftAction(sender)
recipientAction := privateStarGiftAction(recipient)
if sharedAction.GiftMsgID != sourceOwnerBoxID {
return privateSendMediaProjection{}, fmt.Errorf(
"project private star gift source: gift_msg_id %d does not match owner box %d",
sharedAction.GiftMsgID, sourceOwnerBoxID,
)
}
sharedAction.GiftMsgID = 0
senderAction.GiftMsgID = 0
recipientAction.GiftMsgID = 0
if req.SenderUserID == sourceOwnerUserID {
senderAction.GiftMsgID = sourceOwnerBoxID
} else {
recipientAction.GiftMsgID = sourceOwnerBoxID
}
default:
return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: unsupported media")
}
return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil
}
func cloneMessageMedia(media *domain.MessageMedia) (*domain.MessageMedia, error) {
encoded, err := encodeMessageMedia(media)
if err != nil {
return nil, fmt.Errorf("clone private message media: %w", err)
}
cloned, err := decodeMessageMedia(string(encoded))
if err != nil {
return nil, fmt.Errorf("clone private message media: %w", err)
}
return cloned, nil
}
// encodeSharedPrivateStarGiftMedia returns the logical private-message
// envelope for an already viewpoint-projected Star Gift service message.
// Conversation message ids belong to a single owner's message_boxes
// namespace, so the shared row must never retain them. saved_id is likewise
// box-local for user gifts, while channel saved ids remain globally meaningful
// inside the channel gift namespace.
func encodeSharedPrivateStarGiftMedia(media *domain.MessageMedia) ([]byte, error) {
shared, err := cloneMessageMedia(media)
if err != nil {
return nil, err
}
switch {
case privateStarGiftAction(shared) != nil:
action := privateStarGiftAction(shared)
action.GiftMsgID = 0
action.UpgradeMsgID = 0
if action.PeerUserID > 0 || action.To.Type == domain.PeerTypeUser {
action.SavedID = 0
}
case privateStarGiftUniqueAction(shared) != nil:
action := privateStarGiftUniqueAction(shared)
if action.Peer.Type == domain.PeerTypeUser {
action.SavedID = 0
}
default:
return nil, fmt.Errorf("encode shared private star gift media: unsupported media")
}
encoded, err := encodeMessageMedia(shared)
if err != nil {
return nil, fmt.Errorf("encode shared private star gift media: %w", err)
}
return encoded, nil
}
func privateStarGiftAction(media *domain.MessageMedia) *domain.MessageStarGiftAction {
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGift {
return nil
}
return media.ServiceAction.StarGift
}
func privateStarGiftUniqueAction(media *domain.MessageMedia) *domain.MessageStarGiftUniqueAction {
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique {
return nil
}
return media.ServiceAction.StarGiftUnique
}

View file

@ -1,6 +1,7 @@
package postgres
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
@ -46,6 +47,296 @@ func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore, opts ...St
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,
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) ||
@ -122,9 +413,15 @@ WHERE collectible_revision_id=$1 AND crafted
}
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
canCraftAt = starGiftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
if craftChancePermille > 0 {
canCraftAt = starGiftCraftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
}
}
if revision.Issued >= revision.SupplyTotal {
return domain.ErrStarGiftCollectibleSoldOut
@ -144,15 +441,15 @@ WHERE collectible_revision_id=$1 AND crafted
if err != nil {
return err
}
modelID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID)
modelID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID, req.ModelAttributeID)
if err != nil {
return err
}
patternID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID)
patternID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID, req.PatternAttributeID)
if err != nil {
return err
}
backdropID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID)
backdropID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID, req.BackdropAttributeID)
if err != nil {
return err
}
@ -245,6 +542,12 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
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
}
}
result.Saved.UpgradeMsgID = ownerMessageID
if result.Saved.Owner.Type == domain.PeerTypeUser {
edits, err := s.markPrivateStarGiftSourceUpgradedTx(ctx, tx, req, result.Saved, sent)
@ -305,27 +608,69 @@ func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.Uniqu
// peer plus action.peer=channel and action.saved_id.
fromUserID = messageSenderID
}
peer := saved.Owner
savedID := saved.SavedID
canCraftAt := saved.CanCraftAt
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)
// 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: saved.Owner, SavedID: savedID,
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: saved.CanCraftAt,
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: 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 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,
@ -336,30 +681,11 @@ func (s *StarGiftUpgradeStore) markPrivateStarGiftSourceUpgradedTx(
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)
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
@ -367,84 +693,158 @@ func (s *StarGiftUpgradeStore) markPrivateStarGiftSourceUpgradedTx(
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)
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
}
media, err := decodeMessageMedia(box.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode star gift source media: %w", err)
return nil, fmt.Errorf("lock star gift source ref %d: %w", sourceMessageID, 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)
if peerType != string(domain.PeerTypeUser) || peerID <= 0 {
return nil, fmt.Errorf("star gift source ref %d is not private", sourceMessageID)
}
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)
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
OwnerUserID: req.UserID, BoxID: int32(sourceMessageID), PeerType: peerType, PeerID: peerID,
})
if err != nil {
return nil, fmt.Errorf("encode upgraded star gift source media: %w", err)
return nil, fmt.Errorf("load star gift source ref %d: %w", sourceMessageID, err)
}
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
ownerMedia, err := decodeMessageMedia(target.MediaJson)
if err != nil {
return nil, fmt.Errorf("allocate star gift source edit pts: %w", err)
return nil, fmt.Errorf("decode star gift source ref %d: %w", sourceMessageID, err)
}
tag, err := tx.Exec(ctx, `
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 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 tag.RowsAffected() != 1 {
return nil, fmt.Errorf("update star gift source message box lost row")
if len(privateMediaJSON) == 0 {
return nil, fmt.Errorf("upgrade source message missing private media projection")
}
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, `
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 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
}
@ -460,6 +860,27 @@ func starGiftReadyAt(date, delaySeconds int) int {
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...)
@ -536,6 +957,30 @@ func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64,
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" {
@ -634,46 +1079,77 @@ func (s *StarGiftUpgradeStore) loadUpgradeSourceReplay(ctx context.Context, req
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])
messageIDs, err := userStarGiftSourceMessageIDs(ctx, s.db, saved)
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
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) {

View file

@ -0,0 +1,62 @@
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
)
// registerUserStarGiftMessageRef records an owner-scoped service-message alias
// for a user-owned gift. Official clients may continue from a freshly emitted
// messageActionStarGiftUnique or a separate prepaid-upgrade notification and
// pass that message id to a lifecycle RPC, while payments.getSavedStarGifts may
// still expose the original received gift message as the aggregate's primary
// msg_id. expectedUniqueGiftID is zero for an ordinary gift and positive for a
// unique gift; the write boundary never aliases across lifecycle states.
func registerUserStarGiftMessageRef(
ctx context.Context,
tx pgx.Tx,
ownerUserID int64,
msgID int,
savedGiftID int64,
uniqueGiftID int64,
) error {
if ownerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 || uniqueGiftID < 0 {
return fmt.Errorf("register user star gift message ref: invalid identity")
}
tag, err := tx.Exec(ctx, `
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
SELECT $1,$2,p.id
FROM peer_star_gifts p
WHERE p.id=$3 AND p.owner_peer_type='user' AND p.owner_peer_id=$1
AND (($4::bigint=0 AND p.unique_gift_id IS NULL) OR ($4::bigint>0 AND p.unique_gift_id=$4::bigint))
AND p.lifecycle_status='active'
ON CONFLICT(owner_user_id,msg_id) DO UPDATE
SET saved_gift_id=EXCLUDED.saved_gift_id
WHERE star_gift_user_message_refs.saved_gift_id=EXCLUDED.saved_gift_id`, ownerUserID, msgID, savedGiftID, uniqueGiftID)
if err != nil {
return fmt.Errorf("register user star gift message ref: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("register user star gift message ref: identity collision")
}
return nil
}
func userStarGiftMessageRefMatches(
ctx context.Context,
db interface {
QueryRow(context.Context, string, ...any) pgx.Row
},
ownerUserID int64,
msgID int,
savedGiftID int64,
) (bool, error) {
var matches bool
err := db.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3
)`, ownerUserID, msgID, savedGiftID).Scan(&matches)
return matches, err
}

View file

@ -17,6 +17,19 @@ type fakeStoryReadModelCache struct {
flushes int
}
type fakeRPCProjectionReadModelCache struct {
users []int64
}
func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForViewer(int64) {}
func (f *fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForUser(id int64) {
f.users = append(f.users, id)
}
func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForPeer(int64, domain.Peer) {
}
func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForChannel(int64) {}
func (*fakeRPCProjectionReadModelCache) FlushRPCProjectionReadModel() {}
func (f *fakeStoryReadModelCache) InvalidateStoryReadModelViewers(ids ...int64) {
f.mu.Lock()
defer f.mu.Unlock()
@ -78,6 +91,29 @@ func TestReadModelChangeListenerRoutesStoryPeer(t *testing.T) {
}
}
func TestReadModelChangeListenerRoutesUserVisibility(t *testing.T) {
stories := &fakeStoryReadModelCache{}
rpcProjections := &fakeRPCProjectionReadModelCache{}
listener := NewReadModelChangeListener("", ReadModelCacheSet{
Stories: stories,
RPCProjections: rpcProjections,
}, nil)
listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"user","peer_id":777,"version":2}`)
if len(rpcProjections.users) != 1 || rpcProjections.users[0] != 777 {
t.Fatalf("RPC projection invalidations = %v, want [777]", rpcProjections.users)
}
if peers := stories.peersSnapshot(); len(peers) != 1 || peers[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 777}) {
t.Fatalf("story projection invalidations = %+v, want user 777", peers)
}
listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"channel","peer_id":888,"version":3}`)
listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"user","peer_id":0,"version":4}`)
if len(rpcProjections.users) != 1 || len(stories.peersSnapshot()) != 1 {
t.Fatalf("invalid visibility events were not ignored: users=%v peers=%+v", rpcProjections.users, stories.peersSnapshot())
}
}
// TestStoryPeerReadModelNotifyInvalidatesOnStoryWrite 验证 0135 触发器:写 stories /
// story_hidden_peers → story_peer bump → 统一 read-model NOTIFY → 按 owner peer 失效故事投影。
func TestStoryPeerReadModelNotifyInvalidatesOnStoryWrite(t *testing.T) {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,434 @@
package postgres
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
func telegramLoginPGHash(value string) []byte {
sum := sha256.Sum256([]byte(value))
return sum[:]
}
func TestTelegramLoginStorePostgresAtomicStateAndCodeConsumption(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
suffix := now.UnixNano() % 1_000_000_000
users := NewUserStore(pool)
bots := NewBotStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: suffix + 101,
Phone: fmt.Sprintf("1777%09d", suffix),
FirstName: "OIDC Owner",
})
if err != nil {
t.Fatalf("create oidc owner: %v", err)
}
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
AccessHash: suffix + 102,
FirstName: "OIDC Test Bot",
Username: fmt.Sprintf("oidc_%09d_bot", suffix),
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "bot-secret"})
if err != nil {
t.Fatalf("create oidc bot: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID)
})
store := NewTelegramLoginStore(pool)
client, err := store.UpsertTelegramLoginClient(ctx, domain.TelegramLoginClient{
BotUserID: bot.ID,
ClientID: fmt.Sprintf("%d", bot.ID),
SecretHash: telegramLoginPGHash("client-secret"),
SecretVersion: 1,
SigningAlgorithm: domain.TelegramLoginSigningRS256,
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("upsert oidc client: %v", err)
}
redirectURI := fmt.Sprintf("https://rp-%d.example/callback", suffix)
origin := fmt.Sprintf("https://rp-%d.example", suffix)
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI,
NormalizedURL: redirectURI, CreatedAt: now,
}); err != nil {
t.Fatalf("add redirect: %v", err)
}
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedWebOrigin,
NormalizedURL: origin, CreatedAt: now,
}); err != nil {
t.Fatalf("add web origin: %v", err)
}
newRequest := func(label string) domain.TelegramLoginRequest {
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash("request-" + label),
BrowserTokenHash: telegramLoginPGHash("browser-" + label),
BotUserID: bot.ID,
ClientID: client.ClientID,
SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestWeb,
ResponseType: "code",
RedirectURI: redirectURI,
Origin: origin,
Domain: fmt.Sprintf("rp-%d.example", suffix),
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopeBotAccess},
State: "state",
Nonce: "nonce",
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
CodeChallengeMethod: "S256",
Browser: "Firefox",
Platform: "Windows",
IP: "192.0.2.10",
Region: "Test Region",
MatchCodes: []string{"🟢", "🔵", "🟠"},
MatchCode: "🔵",
MatchCodesFirst: true,
Status: domain.TelegramLoginRequestPending,
CreatedAt: now,
ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create request %s: %v", label, err)
}
return request
}
request := newRequest(fmt.Sprintf("race-%d", suffix))
start := make(chan struct{})
errs := make(chan error, 2)
go func() {
<-start
_, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: request.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
WriteAllowed: true,
MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second),
}, suffix+10_000)
errs <- err
}()
go func() {
<-start
_, err := store.DeclineTelegramLoginRequest(ctx, request.ID, owner.ID, now.Add(time.Second))
errs <- err
}()
close(start)
var success, conflict int
for range 2 {
err := <-errs
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
conflict++
default:
t.Fatalf("accept/decline race error: %v", err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("accept/decline success=%d conflict=%d, want 1/1", success, conflict)
}
codeRequest := newRequest(fmt.Sprintf("code-%d", suffix))
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: codeRequest.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
WriteAllowed: true,
MatchCode: codeRequest.MatchCode, ApprovedAt: now.Add(2 * time.Second),
}, suffix+20_000)
if err != nil {
t.Fatalf("approve code request: %v", err)
}
canSend, err := bots.CanBotSendMessage(ctx, bot.ID, owner.ID)
if err != nil || !canSend {
t.Fatalf("bot access after atomic approval = %v,%v", canSend, err)
}
code := domain.TelegramLoginAuthorizationCode{
RequestID: codeRequest.ID,
CodeHash: telegramLoginPGHash(fmt.Sprintf("code-%d", suffix)),
SealedCode: append(make([]byte, 32), 1),
SealNonce: make([]byte, 12),
SealKeyID: "integration-key",
IssuedAt: now.Add(3 * time.Second),
ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
t.Fatalf("put code: %v", err)
}
exchange := domain.TelegramLoginCodeExchange{
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
RedirectURI: codeRequest.RedirectURI, CodeChallenge: codeRequest.CodeChallenge, Now: now.Add(4 * time.Second),
}
start = make(chan struct{})
errs = make(chan error, 8)
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, exchange)
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
success, conflict = 0, 0
for err := range errs {
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
conflict++
default:
t.Fatalf("code consume race error: %v", err)
}
}
if success != 1 || conflict != 7 {
t.Fatalf("code consume success=%d consumed=%d, want 1/7", success, conflict)
}
miniRequest, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-request-%d", suffix)),
BrowserTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-browser-%d", suffix)),
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestMiniApp, ResponseType: "post_message",
RedirectURI: origin + "/", Origin: origin, InAppOrigin: origin, Domain: fmt.Sprintf("rp-%d.example", suffix),
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
Browser: "Telegram Mini App", Platform: "Telegram Mini App", IP: "192.0.2.11", Region: "Test Region",
MatchCodes: []string{"🟢", "🔵", "🟠"}, MatchCode: "🔵", MatchCodesFirst: true,
Status: domain.TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create mini-app request: %v", err)
}
if _, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: miniRequest.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
MatchCode: miniRequest.MatchCode, ApprovedAt: now.Add(5 * time.Second),
}, suffix+25_000); err != nil {
t.Fatalf("approve mini-app request: %v", err)
}
directToken := domain.TelegramLoginAuthorizationCode{
RequestID: miniRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("mini-token-%d", suffix)),
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
IssuedAt: now.Add(6 * time.Second), ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, directToken); err != nil {
t.Fatalf("put mini-app token: %v", err)
}
start = make(chan struct{})
errs = make(chan error, 8)
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, _, _, err := store.ConsumeTelegramLoginDirectToken(ctx, directToken.CodeHash, origin, now.Add(7*time.Second))
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
success, conflict = 0, 0
for err := range errs {
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
conflict++
default:
t.Fatalf("mini-app token consume race error: %v", err)
}
}
if success != 1 || conflict != 7 {
t.Fatalf("mini-app token consume success=%d consumed=%d, want 1/7", success, conflict)
}
if revoked, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, web.Hash, now.Add(5*time.Second)); err != nil || !revoked {
t.Fatalf("revoke web authorization = %v,%v", revoked, err)
}
if listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID); err != nil {
t.Fatalf("list web authorizations: %v", err)
} else {
for _, got := range listed {
if got.Hash == web.Hash {
t.Fatalf("revoked web authorization still listed: %#v", got)
}
}
}
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
return store.DeleteTelegramLoginAllowedURL(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
})
}
func TestTelegramLoginStorePostgresNativeCallbackAndRetention(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
suffix := now.UnixNano() % 1_000_000_000
users := NewUserStore(pool)
bots := NewBotStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: suffix + 301, Phone: fmt.Sprintf("1666%09d", suffix), FirstName: "Native Owner",
})
if err != nil {
t.Fatal(err)
}
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
AccessHash: suffix + 302, FirstName: "Native Login Bot", Username: fmt.Sprintf("native_%09d_bot", suffix),
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "native-bot-secret"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID) })
store := NewTelegramLoginStore(pool)
client, err := store.CreateTelegramLoginClient(ctx, domain.TelegramLoginClient{
BotUserID: bot.ID, ClientID: fmt.Sprintf("%d", bot.ID), SecretHash: telegramLoginPGHash("native-secret"),
SecretVersion: 1, SigningAlgorithm: domain.TelegramLoginSigningRS256, Enabled: true,
CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
const callbackURI = "bedolaga://telegram-login"
nativeApp, err := store.UpsertTelegramLoginNativeApp(ctx, domain.TelegramLoginNativeApp{
BotUserID: bot.ID, Platform: domain.TelegramLoginNativeAndroid, ApplicationID: "dev.bedolaga.demo",
VerificationID: strings.Repeat("A", 64), CallbackURI: callbackURI, VerifiedDisplayName: "Bedolaga Demo",
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
createRequest := func(label string) domain.TelegramLoginRequest {
t.Helper()
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash("native-request-" + label), BrowserTokenHash: telegramLoginPGHash("native-browser-" + label),
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestNative, ResponseType: "code", RedirectURI: callbackURI,
Domain: "dev.bedolaga.demo", Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", CodeChallengeMethod: "S256",
Browser: "TelegramLogin/Android", Platform: "Android", IP: "192.0.2.20", Region: "Test Region",
IsApp: true, VerifiedAppName: "Bedolaga Demo", MatchCodes: []string{}, Status: domain.TelegramLoginRequestPending,
CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create native request: %v", err)
}
return request
}
approve := func(request domain.TelegramLoginRequest, hash int64) domain.TelegramLoginWebAuthorization {
t.Helper()
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: request.ID, Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: "Native Owner", GivenName: "Native"},
ApprovedAt: now.Add(time.Second),
}, hash)
if err != nil {
t.Fatalf("approve native request: %v", err)
}
return web
}
revokedRequest := createRequest(fmt.Sprintf("revoked-%d", suffix))
revokedWeb := approve(revokedRequest, suffix+30_000)
code := domain.TelegramLoginAuthorizationCode{
RequestID: revokedRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("native-code-%d", suffix)),
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
IssuedAt: now.Add(2 * time.Second), ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
t.Fatal(err)
}
if _, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginCodeExchange{
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
RedirectURI: callbackURI, CodeChallenge: revokedRequest.CodeChallenge, Now: now.Add(3 * time.Second),
}); err != nil {
t.Fatalf("consume native code: %v", err)
}
if ok, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, revokedWeb.Hash, now.Add(4*time.Second)); err != nil || !ok {
t.Fatalf("revoke native authorization = %v,%v", ok, err)
}
activeRequest := createRequest(fmt.Sprintf("active-%d", suffix))
activeWeb := approve(activeRequest, suffix+40_000)
deleted, err := store.DeleteExpiredTelegramLoginArtifacts(ctx, now.Add(2*time.Hour), 100)
if err != nil {
t.Fatal(err)
}
if deleted < 2 {
t.Fatalf("retention deleted=%d, want at least code and revoked request", deleted)
}
if _, found, _ := store.GetTelegramLoginRequest(ctx, revokedRequest.ID); found {
t.Fatal("revoked native request survived retention")
}
if _, found, _ := store.GetTelegramLoginRequest(ctx, activeRequest.ID); !found {
t.Fatal("active native request was deleted")
}
listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID)
if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash {
t.Fatalf("active authorization list=%#v err=%v", listed, err)
}
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
return store.DeleteTelegramLoginNativeApp(ctx, client.BotUserID, nativeApp.ID)
})
}
func assertTelegramLoginConfigDeleteTakesClientLock(t *testing.T, pool *pgxpool.Pool, botUserID int64, remove func() (bool, error)) {
t.Helper()
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
var lockedID int64
if err := tx.QueryRow(ctx, `SELECT bot_user_id FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&lockedID); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
deleted, err := remove()
if err == nil && !deleted {
err = errors.New("configuration row was not deleted")
}
result <- err
}()
select {
case err := <-result:
t.Fatalf("configuration delete bypassed client serialization lock: %v", err)
case <-time.After(150 * time.Millisecond):
}
if err := tx.Commit(ctx); err != nil {
t.Fatal(err)
}
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("configuration delete remained blocked after client lock committed")
}
}

View file

@ -340,6 +340,40 @@ func (s *UserStore) SetVerified(ctx context.Context, userID int64, verified bool
return userFromModel(row), nil
}
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。
func (s *UserStore) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) {
row, err := s.q.SetUserSupport(ctx, sqlcgen.SetUserSupportParams{
ID: userID,
Support: support,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
return domain.User{}, fmt.Errorf("set user support: %w", err)
}
return userFromModel(row), nil
}
// SetScamFake 设置/取消用户的 scam 与 fake 标记bot 复用同一路径)。
func (s *UserStore) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) {
if scam && fake {
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
}
row, err := s.q.SetUserScamFake(ctx, sqlcgen.SetUserScamFakeParams{
ID: userID,
Scam: scam,
Fake: fake,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
return domain.User{}, fmt.Errorf("set user scam/fake: %w", err)
}
return userFromModel(row), nil
}
// SweepExpiredPremium 清空到期会员行并返回清理后的用户。
func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) {
if limit <= 0 {
@ -567,6 +601,8 @@ func userFromModel(r sqlcgen.User) domain.User {
Username: r.Username,
CountryCode: r.CountryCode,
Verified: r.Verified,
Scam: r.Scam,
Fake: r.Fake,
Support: r.Support,
Bot: r.IsBot,
BotInfoVersion: int(r.BotInfoVersion),