Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d

This commit is contained in:
onysd 2026-07-20 23:43:51 +03:00
commit ebb0be38d9
355 changed files with 44640 additions and 2320 deletions

View file

@ -0,0 +1,738 @@
package postgres
import (
"context"
"encoding/binary"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// AccountLifecycleStore is the PostgreSQL implementation of the unified
// account tombstone, delayed deletion and deletion notification boundary.
type AccountLifecycleStore struct {
pool *pgxpool.Pool
}
func NewAccountLifecycleStore(pool *pgxpool.Pool) *AccountLifecycleStore {
return &AccountLifecycleStore{pool: pool}
}
func (s *AccountLifecycleStore) AccountDeletionSnapshot(ctx context.Context, userID int64) (domain.AccountDeletionSnapshot, bool, error) {
if s == nil || s.pool == nil || userID == 0 {
return domain.AccountDeletionSnapshot{}, false, nil
}
u, found, err := NewUserStore(s.pool).ByID(ctx, userID)
if err != nil || !found {
return domain.AccountDeletionSnapshot{}, found, err
}
var snapshot = domain.AccountDeletionSnapshot{User: u}
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM account_passwords WHERE user_id = $1 AND has_password),
COALESCE((SELECT password_changed_at FROM account_passwords WHERE user_id = $1), u.created_at)
FROM users u WHERE u.id = $1`, userID).Scan(&snapshot.HasPassword, &snapshot.PasswordUpdatedAt); err != nil {
return domain.AccountDeletionSnapshot{}, false, fmt.Errorf("load account deletion password facts: %w", err)
}
pending, ok, err := pendingAccountDeletion(ctx, s.pool, userID, nil)
if err != nil {
return domain.AccountDeletionSnapshot{}, false, err
}
if ok {
snapshot.Pending = &pending
}
return snapshot, true, nil
}
func (s *AccountLifecycleStore) ScheduleAccountDeletion(ctx context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) {
if s == nil || s.pool == nil || req.UserID == 0 || req.RequestedAt.IsZero() || !req.ExecuteAt.After(req.RequestedAt) {
return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeletionForbidden
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("begin schedule account deletion: %w", err)
}
defer tx.Rollback(ctx)
if err := lockUsersForUpdate(ctx, tx, req.UserID, domain.OfficialSystemUserID); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock schedule account deletion users: %w", err)
}
var deletedAt *time.Time
if err := tx.QueryRow(ctx, `SELECT deleted_at FROM users WHERE id = $1 FOR UPDATE`, req.UserID).Scan(&deletedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionRequest{}, false, domain.ErrUserNotFound
}
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock account deletion user: %w", err)
}
if deletedAt != nil {
return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeleted
}
if existing, ok, err := pendingAccountDeletion(ctx, tx, req.UserID, nil); err != nil {
return domain.AccountDeletionRequest{}, false, err
} else if ok {
if err := tx.Commit(ctx); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit existing account deletion: %w", err)
}
return existing, false, nil
}
row := tx.QueryRow(ctx, `
INSERT INTO account_deletion_requests (
user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
requested_at, execute_at, completed_at`,
req.UserID, authKeyIDToInt64(req.RequesterAuthKeyID), req.Reason,
req.ConfirmHashDigest[:], req.RequestedAt, req.ExecuteAt)
pending, err := scanAccountDeletionRequest(row)
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("insert account deletion request: %w", err)
}
randomID := int64(binary.LittleEndian.Uint64(req.ConfirmHashDigest[:8]))
if randomID == 0 {
randomID = pending.ID
}
if _, err := NewMessageStore(tx).SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: domain.OfficialSystemUserID,
RecipientUserID: req.UserID,
RandomID: randomID,
Message: req.ServiceMessage,
Date: int(req.RequestedAt.Unix()),
}); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("send account deletion confirmation message: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit schedule account deletion: %w", err)
}
return pending, true, nil
}
func (s *AccountLifecycleStore) PendingAccountDeletionByHash(ctx context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) {
if s == nil || s.pool == nil || userID == 0 {
return domain.AccountDeletionRequest{}, false, nil
}
return pendingAccountDeletion(ctx, s.pool, userID, digest[:])
}
func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) {
if s == nil || s.pool == nil || userID == 0 || now.IsZero() || !validAccountDeletionSource(source) {
return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("begin execute account deletion: %w", err)
}
defer tx.Rollback(ctx)
if err := lockUsersForUpdate(ctx, tx, userID); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion user: %w", err)
}
var lockedID int64
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionResult{}, domain.ErrUserNotFound
}
return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion row: %w", err)
}
u, found, err := NewUserStore(tx).ByID(ctx, userID)
if err != nil {
return domain.AccountDeletionResult{}, err
}
if !found {
return domain.AccountDeletionResult{}, domain.ErrUserNotFound
}
if u.Deleted {
return domain.AccountDeletionResult{User: u, Changed: false}, nil
}
if u.Bot || domain.IsSystemUserID(u.ID) {
return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden
}
due, err := accountDeletionStillDue(ctx, tx, u, source, now)
if err != nil {
return domain.AccountDeletionResult{}, err
}
if !due {
return domain.AccountDeletionResult{User: u, Changed: false}, nil
}
if err := enqueueAccountDeletionNotifications(ctx, tx, userID); err != nil {
return domain.AccountDeletionResult{}, err
}
if err := settleDeletedAccountFinancialState(ctx, tx, userID, now); err != nil {
return domain.AccountDeletionResult{}, err
}
revoked, err := revokeByUserExceptTx(ctx, tx, userID, 0)
if err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("revoke deleted account authorizations: %w", err)
}
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
return domain.AccountDeletionResult{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, ""); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
}
reason = strings.TrimSpace(reason)
reason = truncateUTF8Bytes(reason, 1024)
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 = $3, deletion_reason = $4,
account_delete_at = NULL, updated_at = $2
WHERE id = $1 AND deleted_at IS NULL`, userID, now, string(source), reason); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("write deleted account tombstone: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE account_deletion_requests
SET state = 'executed', completed_at = $2, updated_at = $2
WHERE user_id = $1 AND state = 'pending'`, userID, now); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("complete account deletion request: %w", err)
}
u, found, err = NewUserStore(tx).ByID(ctx, userID)
if err != nil || !found {
if err == nil {
err = domain.ErrUserNotFound
}
return domain.AccountDeletionResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("commit execute account deletion: %w", err)
}
return domain.AccountDeletionResult{User: u, Changed: true, RevokedAuthorizations: revoked}, nil
}
func (s *AccountLifecycleStore) CancelAccountDeletion(ctx context.Context, userID int64, digest [32]byte, now time.Time) ([]domain.Authorization, error) {
if s == nil || s.pool == nil || userID == 0 || now.IsZero() {
return nil, domain.ErrAccountDeletionHashInvalid
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin cancel account deletion: %w", err)
}
defer tx.Rollback(ctx)
if err := lockUsersForUpdate(ctx, tx, userID); err != nil {
return nil, fmt.Errorf("lock cancel account deletion: %w", err)
}
pending, ok, err := pendingAccountDeletionForUpdate(ctx, tx, userID, digest[:])
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrAccountDeletionHashInvalid
}
revoked, err := revokeOneAuthorizationTx(ctx, tx, userID, pending.RequesterAuthKeyID)
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
UPDATE account_deletion_requests
SET state = 'cancelled', completed_at = $2, updated_at = $2
WHERE id = $1 AND state = 'pending'`, pending.ID, now); err != nil {
return nil, fmt.Errorf("cancel account deletion request: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit cancel account deletion: %w", err)
}
return revoked, nil
}
func (s *AccountLifecycleStore) DueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionCandidate, error) {
if s == nil || s.pool == nil || limit <= 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
WITH candidates AS (
SELECT user_id, 'password_reset_expiry'::text AS source, execute_at AS due_at, 1 AS priority
FROM account_deletion_requests WHERE state = 'pending' AND execute_at <= $1
UNION ALL
SELECT id, 'account_ttl', account_delete_at, 2
FROM users WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at <= $1
UNION ALL
SELECT r.user_id, 'freeze_expiry', r.frozen_until, 3
FROM account_restrictions r JOIN users u ON u.id = r.user_id
WHERE r.frozen = true AND r.frozen_until IS NOT NULL AND r.frozen_until <= $1
AND u.deleted_at IS NULL AND u.is_bot = false
), dedup AS (
SELECT DISTINCT ON (user_id) user_id, source, due_at
FROM candidates ORDER BY user_id, priority, due_at
)
SELECT user_id, source, due_at FROM dedup ORDER BY due_at, user_id LIMIT $2`, now, limit)
if err != nil {
return nil, fmt.Errorf("list due account deletions: %w", err)
}
defer rows.Close()
out := make([]domain.AccountDeletionCandidate, 0)
for rows.Next() {
var c domain.AccountDeletionCandidate
var source string
if err := rows.Scan(&c.UserID, &source, &c.DueAt); err != nil {
return nil, fmt.Errorf("scan due account deletion: %w", err)
}
c.Source = domain.AccountDeletionSource(source)
out = append(out, c)
}
return out, rows.Err()
}
func (s *AccountLifecycleStore) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
if s == nil || s.pool == nil || limit <= 0 || lease <= 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
WITH claim AS (
SELECT id FROM account_deletion_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_deletion_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.deleted_user_id, n.attempts`, now, limit, now.Add(lease))
if err != nil {
return nil, fmt.Errorf("claim account deletion notifications: %w", err)
}
defer rows.Close()
out := make([]domain.AccountDeletionNotification, 0)
for rows.Next() {
var n domain.AccountDeletionNotification
if err := rows.Scan(&n.ID, &n.TargetUserID, &n.DeletedUserID, &n.Attempts); err != nil {
return nil, fmt.Errorf("scan account deletion notification: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}
func (s *AccountLifecycleStore) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
_, err := s.pool.Exec(ctx, `UPDATE account_deletion_notifications
SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $2 WHERE id = $1`, id, now)
if err != nil {
return fmt.Errorf("complete account deletion notification: %w", err)
}
return nil
}
type accountDeletionRowScanner interface {
Scan(dest ...any) error
}
func scanAccountDeletionRequest(row accountDeletionRowScanner) (domain.AccountDeletionRequest, error) {
var (
r domain.AccountDeletionRequest
authKey int64
state string
digest []byte
completedAt *time.Time
)
if err := row.Scan(&r.ID, &r.UserID, &authKey, &state, &r.Reason, &digest,
&r.RequestedAt, &r.ExecuteAt, &completedAt); err != nil {
return domain.AccountDeletionRequest{}, err
}
if len(digest) != len(r.ConfirmHashDigest) {
return domain.AccountDeletionRequest{}, fmt.Errorf("invalid account deletion digest length %d", len(digest))
}
copy(r.ConfirmHashDigest[:], digest)
r.RequesterAuthKeyID = authKeyIDFromInt64(authKey)
r.State = domain.AccountDeletionRequestState(state)
if completedAt != nil {
r.CompletedAt = *completedAt
}
return r, nil
}
func pendingAccountDeletion(ctx context.Context, db interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) {
query := `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
requested_at, execute_at, completed_at FROM account_deletion_requests
WHERE user_id = $1 AND state = 'pending'`
args := []any{userID}
if digest != nil {
query += ` AND confirm_hash_digest = $2`
args = append(args, digest)
}
r, err := scanAccountDeletionRequest(db.QueryRow(ctx, query, args...))
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionRequest{}, false, nil
}
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("load pending account deletion: %w", err)
}
return r, true, nil
}
func pendingAccountDeletionForUpdate(ctx context.Context, tx pgx.Tx, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) {
row := tx.QueryRow(ctx, `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
requested_at, execute_at, completed_at FROM account_deletion_requests
WHERE user_id = $1 AND confirm_hash_digest = $2 AND state = 'pending' FOR UPDATE`, userID, digest)
r, err := scanAccountDeletionRequest(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionRequest{}, false, nil
}
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock pending account deletion: %w", err)
}
return r, true, nil
}
func validAccountDeletionSource(source domain.AccountDeletionSource) bool {
switch source {
case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline,
domain.AccountDeletionPasswordResetExpiry, domain.AccountDeletionAccountTTL, domain.AccountDeletionFreezeExpiry:
return true
default:
return false
}
}
// accountDeletionStillDue closes the list-then-execute race for every scheduled
// source. The user row is already locked; source-specific facts are read and,
// where applicable, locked again immediately before destructive work begins.
// Manual sources are admission decisions made by the caller and have no
// independently mutable deadline to revalidate.
func accountDeletionStillDue(ctx context.Context, tx pgx.Tx, user domain.User, source domain.AccountDeletionSource, now time.Time) (bool, error) {
switch source {
case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline:
return true, nil
case domain.AccountDeletionPasswordResetExpiry:
var requestID int64
err := tx.QueryRow(ctx, `
SELECT id FROM account_deletion_requests
WHERE user_id = $1 AND state = 'pending' AND execute_at <= $2
ORDER BY execute_at, id LIMIT 1 FOR UPDATE`, user.ID, now).Scan(&requestID)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("revalidate pending account deletion: %w", err)
}
return true, nil
case domain.AccountDeletionAccountTTL:
return !user.AccountDeleteAt.IsZero() && !user.AccountDeleteAt.After(now), nil
case domain.AccountDeletionFreezeExpiry:
var frozen bool
var frozenUntil *time.Time
err := tx.QueryRow(ctx, `
SELECT frozen, frozen_until FROM account_restrictions WHERE user_id = $1 FOR UPDATE`, user.ID).Scan(&frozen, &frozenUntil)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("revalidate frozen account deletion: %w", err)
}
return frozen && frozenUntil != nil && !frozenUntil.After(now), nil
default:
return false, domain.ErrAccountDeletionForbidden
}
}
func truncateUTF8Bytes(value string, maxBytes int) string {
if maxBytes < 1 {
return ""
}
if len(value) <= maxBytes {
return value
}
cut := maxBytes
for cut > 0 && !utf8.ValidString(value[:cut]) {
cut--
}
return value[:cut]
}
func enqueueAccountDeletionNotifications(ctx context.Context, tx pgx.Tx, userID int64) error {
const maxAccountDeletionNotificationAudience = 4096
_, err := tx.Exec(ctx, `
INSERT INTO account_deletion_notifications (target_user_id, deleted_user_id)
SELECT audience.user_id, $1
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 $2
) 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, deleted_user_id) DO NOTHING`, userID, maxAccountDeletionNotificationAudience)
if err != nil {
return fmt.Errorf("enqueue account deletion notifications: %w", err)
}
return nil
}
func revokeOneAuthorizationTx(ctx context.Context, tx pgx.Tx, userID int64, authKeyID [8]byte) ([]domain.Authorization, error) {
id := authKeyIDToInt64(authKeyID)
if id == 0 {
return nil, nil
}
if err := lockPermanentAuthIdentities(ctx, tx, []int64{id}); err != nil {
return nil, err
}
var locked int64
if err := tx.QueryRow(ctx, `SELECT auth_key_id FROM auth_keys WHERE auth_key_id = $1 FOR UPDATE`, id).Scan(&locked); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("lock password reset auth key: %w", err)
}
a, found, err := scanRevokedAuthorization(tx.QueryRow(ctx, `
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, userID))
if err != nil {
return nil, fmt.Errorf("load password reset authorization: %w", err)
}
if !found {
return nil, nil
}
if err := deleteRevocationTargetsTx(ctx, tx, []int64{id}); err != nil {
return nil, err
}
return []domain.Authorization{a}, nil
}
func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
// Leave shared private_messages/channel_messages and immutable transaction
// ledgers intact. Only the deleted user's private projections and settings are
// removed; other users continue to reference the tombstone sender.
statements := []string{
`DELETE FROM account_privacy_rules WHERE owner_user_id = $1`,
`DELETE FROM account_reaction_settings WHERE user_id = $1`,
`DELETE FROM account_restrictions WHERE user_id = $1`,
`DELETE FROM account_settings WHERE user_id = $1`,
`DELETE FROM account_passwords WHERE user_id = $1`,
`DELETE FROM notify_settings WHERE owner_user_id = $1`,
`DELETE FROM passkey_credentials WHERE user_id = $1`,
`DELETE FROM contacts WHERE user_id = $1 OR contact_user_id = $1`,
`DELETE FROM contact_blocks WHERE owner_user_id = $1 OR blocked_user_id = $1`,
`DELETE FROM dialog_drafts WHERE user_id = $1`,
`DELETE FROM dialog_filter_settings WHERE user_id = $1`,
`DELETE FROM dialog_filters WHERE user_id = $1`,
`DELETE FROM chatlist_memberships WHERE user_id = $1 OR owner_user_id = $1`,
`DELETE FROM chatlist_invites WHERE owner_user_id = $1`,
`DELETE FROM saved_dialog_pins WHERE user_id = $1`,
`DELETE FROM message_box_media WHERE owner_user_id = $1`,
`DELETE FROM private_media_category_counts WHERE owner_user_id = $1`,
`DELETE FROM message_boxes WHERE owner_user_id = $1`,
`DELETE FROM dialogs WHERE user_id = $1`,
`DELETE FROM dispatch_outbox WHERE target_user_id = $1`,
`DELETE FROM dispatch_outbox_user_heads WHERE target_user_id = $1`,
`DELETE FROM user_update_events WHERE user_id = $1`,
`DELETE FROM user_update_retention WHERE user_id = $1`,
`DELETE FROM user_update_watermarks WHERE user_id = $1`,
`DELETE FROM update_states WHERE user_id = $1`,
`DELETE FROM bootstrap_update_jobs WHERE user_id = $1`,
`DELETE FROM scheduled_messages WHERE owner_user_id = $1`,
`DELETE FROM quick_reply_messages WHERE owner_user_id = $1`,
`DELETE FROM quick_replies WHERE owner_user_id = $1`,
`DELETE FROM saved_music WHERE user_id = $1`,
`DELETE FROM user_sticker_collections WHERE owner_user_id = $1`,
`DELETE FROM user_sticker_sets WHERE owner_user_id = $1`,
`DELETE FROM user_recent_reactions WHERE user_id = $1`,
`DELETE FROM user_saved_reaction_tags WHERE user_id = $1`,
`DELETE FROM user_top_reactions WHERE user_id = $1`,
`DELETE FROM theme_user_installs WHERE user_id = $1`,
`DELETE FROM peer_translation_settings WHERE user_id = $1`,
`DELETE FROM ai_compose_tone_saves WHERE user_id = $1`,
`DELETE FROM ai_compose_tones WHERE owner_user_id = $1`,
`DELETE FROM business_automation_deliveries WHERE owner_user_id = $1 OR peer_user_id = $1`,
`DELETE FROM business_connected_bot_peer_states WHERE owner_user_id = $1 OR peer_user_id = $1`,
`DELETE FROM business_connected_bots WHERE owner_user_id = $1`,
`DELETE FROM business_chat_links WHERE owner_user_id = $1`,
`DELETE FROM user_business_profiles WHERE user_id = $1`,
`DELETE FROM attach_menu_user_states WHERE user_id = $1`,
`DELETE FROM bot_emoji_status_permissions WHERE user_id = $1`,
`DELETE FROM bot_user_permissions WHERE user_id = $1`,
`DELETE FROM login_code_message_deliveries WHERE user_id = $1`,
`DELETE FROM webview_custom_method_queries WHERE user_id = $1`,
`DELETE FROM webview_requested_buttons WHERE user_id = $1`,
`DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = $1`,
`DELETE FROM story_views WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM story_exposures WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM story_read_states WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM story_hidden_peers WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM stories WHERE owner_peer_type = 'user' AND owner_peer_id = $1`,
`DELETE FROM group_call_schedule_subscribers WHERE user_id = $1`,
`DELETE FROM group_call_participants WHERE user_id = $1`,
`DELETE FROM group_call_invites WHERE inviter_user_id = $1 OR invitee_user_id = $1`,
`DELETE FROM channel_boost_slots WHERE user_id = $1`,
`DELETE FROM channel_invite_importers WHERE user_id = $1`,
`DELETE FROM channel_topic_read WHERE user_id = $1`,
`DELETE FROM channel_unread_mentions WHERE user_id = $1`,
`DELETE FROM channel_unread_mention_index WHERE user_id = $1`,
`DELETE FROM channel_dialogs WHERE user_id = $1`,
`DELETE FROM user_channel_member_index WHERE user_id = $1`,
`DELETE FROM account_deletion_notifications WHERE target_user_id = $1`,
`DELETE FROM uploaded_media_receipts WHERE owner_user_id = $1`,
`DELETE FROM upload_parts WHERE owner_user_id = $1`,
`DELETE FROM encrypted_files WHERE owner_user_id = $1`,
}
for _, statement := range statements {
if _, err := tx.Exec(ctx, statement, userID); err != nil {
return fmt.Errorf("purge deleted account private state (%s): %w", statement, err)
}
}
if _, err := tx.Exec(ctx, `
WITH changed AS (
UPDATE channel_members
SET status = 'left', left_at = $2, unread_mark = false, updated_at = $3
WHERE user_id = $1 AND status = 'active'
RETURNING channel_id, role
), counts AS (
SELECT channel_id, count(*) AS participants,
count(*) FILTER (WHERE role IN ('creator', 'admin')) AS admins
FROM changed GROUP BY channel_id
)
UPDATE channels c
SET participants_count = GREATEST(0, c.participants_count - counts.participants::int),
admins_count = GREATEST(0, c.admins_count - counts.admins::int),
updated_at = $3
FROM counts WHERE c.id = counts.channel_id`, userID, int(now.Unix()), now); err != nil {
return fmt.Errorf("leave deleted account channels: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE secret_chats SET state = 'discarded', history_deleted = true,
g_a = ''::bytea, g_b = ''::bytea, key_fingerprint = 0
WHERE admin_user_id = $1 OR participant_user_id = $1`, userID); err != nil {
return fmt.Errorf("discard deleted account secret chats: %w", err)
}
return nil
}
func settleDeletedAccountFinancialState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
nowUnix := int(now.Unix())
rows, err := tx.Query(ctx, `
SELECT id, buyer_user_id, currency, amount
FROM star_gift_offers
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND status = 'pending'
ORDER BY id FOR UPDATE`, userID)
if err != nil {
return fmt.Errorf("lock deleted account gift offers: %w", err)
}
type offer struct {
id, buyer, amount int64
currency string
}
offers := make([]offer, 0)
for rows.Next() {
var o offer
if err := rows.Scan(&o.id, &o.buyer, &o.currency, &o.amount); err != nil {
rows.Close()
return fmt.Errorf("scan deleted account gift offer: %w", err)
}
offers = append(offers, o)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
for _, o := range offers {
var balance int64
if o.currency == "XTR" {
if err := tx.QueryRow(ctx, `
INSERT INTO stars_balances (user_id, balance) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET balance = stars_balances.balance + EXCLUDED.balance, updated_at = now()
RETURNING balance`, o.buyer, o.amount).Scan(&balance); err != nil {
return fmt.Errorf("refund deleted account stars offer: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
(user_id, peer_type, peer_id, amount, reason, title, description, date)
VALUES ($1, 'user', $2, $3, 'gift_offer_refund_account_deleted', 'Gift offer refunded', '', $4)`, o.buyer, userID, o.amount, nowUnix); err != nil {
return fmt.Errorf("record deleted account stars refund: %w", err)
}
} else {
if err := tx.QueryRow(ctx, `
INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET balance_nanoton = ton_balances.balance_nanoton + EXCLUDED.balance_nanoton, updated_at = now()
RETURNING balance_nanoton`, o.buyer, o.amount).Scan(&balance); err != nil {
return fmt.Errorf("refund deleted account TON offer: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
(user_id, amount_nanoton, reason, peer_type, peer_id, date)
VALUES ($1, $2, 'gift_offer_refund_account_deleted', 'user', $3, $4)`, o.buyer, o.amount, userID, nowUnix); err != nil {
return fmt.Errorf("record deleted account TON refund: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
SET status = 'cancelled', resolved_at = $2, balance_after = $3
WHERE id = $1 AND status = 'pending'`, o.id, nowUnix, balance); err != nil {
return fmt.Errorf("cancel deleted account gift offer: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
SET status = 'cancelled', resolved_at = $2, balance_after = 0
WHERE buyer_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
return fmt.Errorf("cancel deleted buyer gift offers: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests
SET status = 'failed', completed_at = $2 WHERE owner_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
return fmt.Errorf("fail deleted account withdrawals: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active = false, version = version + 1
WHERE bidder_user_id = $1 AND active = true`, userID); err != nil {
return fmt.Errorf("deactivate deleted account auction bids: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts
SET burned = true, owner_name = '', updated_at = $2
WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID, now); err != nil {
return fmt.Errorf("burn deleted account unique gifts: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts
SET lifecycle_status = 'burned', unsaved = true, pinned_order = 0
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NOT NULL`, userID); err != nil {
return fmt.Errorf("burn deleted account saved gifts: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM peer_star_gifts
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NULL`, userID); err != nil {
return fmt.Errorf("delete deleted account regular gifts: %w", err)
}
var stars int64
if err := tx.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&stars); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("lock deleted account stars balance: %w", err)
}
if stars != 0 {
if _, err := tx.Exec(ctx, `UPDATE stars_balances SET balance = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
return fmt.Errorf("zero deleted account stars: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
(user_id, peer_type, peer_id, amount, reason, title, description, date)
VALUES ($1, 'user', $1, $2, 'account_deleted', 'Account deleted', '', $3)`, userID, -stars, nowUnix); err != nil {
return fmt.Errorf("record deleted account stars clearing: %w", err)
}
}
var ton int64
if err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&ton); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("lock deleted account TON balance: %w", err)
}
if ton != 0 {
if _, err := tx.Exec(ctx, `UPDATE ton_balances SET balance_nanoton = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
return fmt.Errorf("zero deleted account TON: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
(user_id, amount_nanoton, reason, date) VALUES ($1, $2, 'account_deleted', $3)`, userID, -ton, nowUnix); err != nil {
return fmt.Errorf("record deleted account TON clearing: %w", err)
}
}
return nil
}

View file

@ -0,0 +1,289 @@
package postgres
import (
"context"
"crypto/rand"
"crypto/sha256"
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
nonce := time.Now().UnixNano()
users := NewUserStore(pool)
deleted := createTestUser(t, ctx, users, fmt.Sprintf("15571%d", nonce), "Delete", "Me")
peer := createTestUser(t, ctx, users, fmt.Sprintf("15572%d", nonce), "Keep", "Peer")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM stars_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM stars_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM private_messages WHERE sender_user_id = ANY($1) OR recipient_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, []int64{deleted.ID, peer.ID})
})
authOne := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 1)
authTwo := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 2)
if _, err := pool.Exec(ctx, `INSERT INTO contacts
(user_id, contact_user_id, contact_phone, contact_first_name, contact_last_name)
VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err != nil {
t.Fatalf("insert reverse contact: %v", err)
}
if _, err := NewMessageStore(pool).SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: deleted.ID, RecipientUserID: peer.ID, RandomID: nonce, Message: "keep shared history",
}); err != nil {
t.Fatalf("send shared message: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)`, deleted.ID); err != nil {
t.Fatalf("insert account settings: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO stars_balances (user_id, balance) VALUES ($1, 50)`, deleted.ID); err != nil {
t.Fatalf("insert stars balance: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, 100)`, deleted.ID); err != nil {
t.Fatalf("insert TON balance: %v", err)
}
lifecycle := NewAccountLifecycleStore(pool)
now := time.Now().UTC().Truncate(time.Second)
digestOne := sha256.Sum256([]byte("confirm-one"))
pending, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
UserID: deleted.ID, RequesterAuthKeyID: authOne, Reason: "Forgot password",
ConfirmHashDigest: digestOne, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-one",
RequestedAt: now, ExecuteAt: now.Add(7 * 24 * time.Hour),
})
if err != nil || !created || pending.UserID != deleted.ID {
t.Fatalf("schedule deletion = %+v created=%v err=%v", pending, created, err)
}
if got, found, err := lifecycle.PendingAccountDeletionByHash(ctx, deleted.ID, digestOne); err != nil || !found || got.ID != pending.ID {
t.Fatalf("pending deletion by hash = %+v found=%v err=%v", got, found, err)
}
revoked, err := lifecycle.CancelAccountDeletion(ctx, deleted.ID, digestOne, now.Add(time.Minute))
if err != nil || len(revoked) != 1 || revoked[0].AuthKeyID != authOne {
t.Fatalf("cancel deletion revoked=%+v err=%v", revoked, err)
}
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authOne); err != nil || found {
t.Fatalf("requester auth key after cancel found=%v err=%v, want revoked", found, err)
}
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found {
t.Fatalf("other auth key after cancel found=%v err=%v, want retained", found, err)
}
result, err := lifecycle.ExecuteAccountDeletion(ctx, deleted.ID, domain.AccountDeletionManual, "manual", now.Add(2*time.Minute))
if err != nil {
t.Fatalf("execute account deletion: %v", err)
}
if !result.Changed || !result.User.Deleted || result.User.Phone != "" || result.User.FirstName != "" || len(result.RevokedAuthorizations) != 1 {
t.Fatalf("deletion result = %+v", result)
}
if _, found, err := users.ByPhone(ctx, deleted.Phone); err != nil || found {
t.Fatalf("released phone found=%v err=%v", found, err)
}
if tombstone, found, err := users.ByID(ctx, deleted.ID); err != nil || !found || !tombstone.Deleted || tombstone.FirstName != "" {
t.Fatalf("tombstone = %+v found=%v err=%v", tombstone, found, err)
}
if _, err := users.UpdateProfile(ctx, deleted.ID, "Resurrected", "", ""); err == nil {
t.Fatal("deleted account profile mutation unexpectedly succeeded")
}
history, err := NewMessageStore(pool).ListByUser(ctx, peer.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
Limit: 10,
})
if err != nil || len(history.Messages) != 1 || history.Messages[0].Body != "keep shared history" || history.Messages[0].From.ID != deleted.ID {
t.Fatalf("peer history after deletion = %+v err=%v", history, err)
}
var peerBoxes, settings, contacts, notifications int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND from_user_id = $2`, peer.ID, deleted.ID).Scan(&peerBoxes); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_settings WHERE user_id = $1`, deleted.ID).Scan(&settings); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM contacts WHERE user_id = $1 OR contact_user_id = $1`, deleted.ID).Scan(&contacts); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_deletion_notifications WHERE target_user_id = $1 AND deleted_user_id = $2`, peer.ID, deleted.ID).Scan(&notifications); err != nil {
t.Fatal(err)
}
if peerBoxes != 1 || settings != 0 || contacts != 0 || notifications != 1 {
t.Fatalf("post-delete state peerBoxes=%d settings=%d contacts=%d notifications=%d", peerBoxes, settings, contacts, notifications)
}
var stars, ton, starClear, tonClear int64
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1`, deleted.ID).Scan(&stars); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1`, deleted.ID).Scan(&ton); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount), 0) FROM stars_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&starClear); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_nanoton), 0) FROM ton_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&tonClear); err != nil {
t.Fatal(err)
}
if stars != 0 || ton != 0 || starClear != -50 || tonClear != -100 {
t.Fatalf("financial clearing stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear)
}
}
func TestAccountLifecycleDueSourcesAndTTLWatermarkPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
nonce := time.Now().UnixNano()
users := NewUserStore(pool)
ttlUser := createTestUser(t, ctx, users, fmt.Sprintf("15671%d", nonce), "TTL", "User")
freezeUser := createTestUser(t, ctx, users, fmt.Sprintf("15672%d", nonce), "Frozen", "User")
pendingUser := createTestUser(t, ctx, users, fmt.Sprintf("15673%d", nonce), "Pending", "User")
ids := []int64{ttlUser.ID, freezeUser.ID, pendingUser.ID}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, ids)
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, ids)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, ids)
})
now := time.Now().UTC().Truncate(time.Second)
if _, err := pool.Exec(ctx, `UPDATE users SET account_delete_at = $2 WHERE id = $1`, ttlUser.ID, now.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO account_restrictions
(user_id, frozen, reason, actor, command_id, frozen_since, frozen_until, appeal_url)
VALUES ($1, true, 'abuse', 'test', 'freeze-test', $2, $3, 'https://example.test/appeal')`, freezeUser.ID, now.Add(-time.Hour), now.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256([]byte("due-pending"))
if _, err := pool.Exec(ctx, `INSERT INTO account_deletion_requests
(user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at)
VALUES ($1, 123, 'forgot', $2, $3, $4)`, pendingUser.ID, digest[:], now.Add(-8*24*time.Hour), now.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
candidates, err := NewAccountLifecycleStore(pool).DueAccountDeletions(ctx, now, 10)
if err != nil {
t.Fatal(err)
}
sources := make(map[int64]domain.AccountDeletionSource, len(candidates))
for _, candidate := range candidates {
sources[candidate.UserID] = candidate.Source
}
if sources[ttlUser.ID] != domain.AccountDeletionAccountTTL || sources[freezeUser.ID] != domain.AccountDeletionFreezeExpiry || sources[pendingUser.ID] != domain.AccountDeletionPasswordResetExpiry {
t.Fatalf("due sources = %+v", sources)
}
seen := now.Add(time.Hour)
if err := users.UpdateLastSeen(ctx, ttlUser.ID, int(seen.Unix())); err != nil {
t.Fatal(err)
}
lifecycle := NewAccountLifecycleStore(pool)
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, ttlUser.ID, domain.AccountDeletionAccountTTL, "", now); err != nil || stale.Changed {
t.Fatalf("stale TTL candidate changed=%v err=%v", stale.Changed, err)
}
if _, err := pool.Exec(ctx, `UPDATE account_restrictions SET frozen_until = $2, updated_at = $3 WHERE user_id = $1`, freezeUser.ID, now.Add(24*time.Hour), now); err != nil {
t.Fatal(err)
}
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, freezeUser.ID, domain.AccountDeletionFreezeExpiry, "", now); err != nil || stale.Changed {
t.Fatalf("extended freeze candidate changed=%v err=%v", stale.Changed, err)
}
if _, err := pool.Exec(ctx, `UPDATE account_deletion_requests SET state = 'cancelled', completed_at = $2, updated_at = $2 WHERE user_id = $1`, pendingUser.ID, now); err != nil {
t.Fatal(err)
}
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, pendingUser.ID, domain.AccountDeletionPasswordResetExpiry, "", now); err != nil || stale.Changed {
t.Fatalf("cancelled pending candidate changed=%v err=%v", stale.Changed, err)
}
var deadline time.Time
if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
t.Fatal(err)
}
if want := seen.Add(365 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
t.Fatalf("TTL watermark deadline=%v want=%v", deadline, want)
}
if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)
ON CONFLICT (user_id) DO UPDATE SET account_ttl_days = EXCLUDED.account_ttl_days`, ttlUser.ID); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
t.Fatal(err)
}
if want := seen.Add(30 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
t.Fatalf("custom TTL deadline=%v want=%v", deadline, want)
}
}
func TestAccountPasswordChangedAtIgnoresSRPChallengeRotationPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
user := createTestUser(t, ctx, NewUserStore(pool), fmt.Sprintf("15771%d", time.Now().UnixNano()), "Password", "Clock")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, user.ID) })
if _, err := pool.Exec(ctx, `INSERT INTO account_passwords
(user_id, has_password, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_verifier, srp_id, srp_b)
VALUES ($1, true, '\x01', '\x02', 3, '\x03', '\x04', 10, '\x05')`, user.ID); err != nil {
t.Fatal(err)
}
var initial, afterChallenge, afterPassword time.Time
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&initial); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_id = 11, srp_b = '\x06', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterChallenge); err != nil {
t.Fatal(err)
}
if !afterChallenge.Equal(initial) {
t.Fatalf("SRP challenge rotation changed password clock: initial=%v after=%v", initial, afterChallenge)
}
if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_verifier = '\x07', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterPassword); err != nil {
t.Fatal(err)
}
if !afterPassword.After(afterChallenge) {
t.Fatalf("password verifier change did not advance clock: before=%v after=%v", afterChallenge, afterPassword)
}
}
func TestTruncateAccountDeletionReasonUTF8(t *testing.T) {
got := truncateUTF8Bytes(strings.Repeat("界", 400), 1024)
if !utf8.ValidString(got) || len(got) > 1024 {
t.Fatalf("truncateUTF8Bytes returned invalid result: valid=%v bytes=%d", utf8.ValidString(got), len(got))
}
if got == "" {
t.Fatal("truncateUTF8Bytes unexpectedly removed the whole reason")
}
}
func saveLifecycleTestAuthorization(t *testing.T, ctx context.Context, db *pgxpool.Pool, userID int64, marker byte) [8]byte {
t.Helper()
var id [8]byte
var value [256]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatal(err)
}
id[0] = marker
if _, err := rand.Read(value[:]); err != nil {
t.Fatal(err)
}
if err := NewAuthKeyStore(db).Save(ctx, store.AuthKeyData{ID: id, Value: value}); err != nil {
t.Fatalf("save lifecycle auth key: %v", err)
}
if err := NewAuthorizationStore(db).Bind(ctx, domain.Authorization{AuthKeyID: id, UserID: userID, Hash: int64(marker)}); err != nil {
t.Fatalf("bind lifecycle authorization: %v", err)
}
return id
}

View file

@ -183,6 +183,22 @@ func TestBotStoreRoundTripPostgres(t *testing.T) {
if flagBot, _, _ := bots.GetBot(ctx, bot1.ID); !flagBot.Nochats || !flagBot.ChatHistory {
t.Fatalf("flags = nochats=%v chat_history=%v, want both true", flagBot.Nochats, flagBot.ChatHistory)
}
requestedButton := domain.BotRequestedWebViewButton{
WebAppReqID: fmt.Sprintf("pg-requested-%d", suffix), BotUserID: bot1.ID, UserID: owner.ID,
ButtonID: 45, Text: "Share", PeerType: "user", MaxQuantity: 2,
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour),
}
t.Cleanup(func() {
_ = bots.DeleteRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID)
})
if err := bots.SaveRequestedWebViewButton(ctx, requestedButton); err != nil {
t.Fatalf("save requested button: %v", err)
}
storedButton, found, err := bots.GetRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID)
if err != nil || !found || !storedButton.NameRequested || !storedButton.UsernameRequested || !storedButton.PhotoRequested {
t.Fatalf("requested button=%#v found=%v err=%v", storedButton, found, err)
}
if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || can {
t.Fatalf("CanBotSendMessage before allow = %v,%v, want false,nil", can, err)
}

View file

@ -416,16 +416,29 @@ func (s *BotStore) SaveRequestedWebViewButton(ctx context.Context, button domain
if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ExpiresAt.IsZero() {
return domain.ErrBotRequestedButtonInvalid
}
_, err := s.db.Exec(ctx, `
INSERT INTO webview_requested_buttons (webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
peerFilter, err := json.Marshal(button.PeerFilter)
if err != nil {
return domain.ErrBotRequestedButtonInvalid
}
_, err = s.db.Exec(ctx, `
INSERT INTO webview_requested_buttons (
webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity,
peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (webapp_req_id) DO UPDATE SET
button_id=EXCLUDED.button_id,
text=EXCLUDED.text,
peer_type=EXCLUDED.peer_type,
max_quantity=EXCLUDED.max_quantity,
peer_filter=EXCLUDED.peer_filter,
name_requested=EXCLUDED.name_requested,
username_requested=EXCLUDED.username_requested,
photo_requested=EXCLUDED.photo_requested,
expires_at=EXCLUDED.expires_at`,
button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text, button.PeerType, button.MaxQuantity, button.CreatedAt, button.ExpiresAt)
button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text,
button.PeerType, button.MaxQuantity, peerFilter, button.NameRequested,
button.UsernameRequested, button.PhotoRequested, button.CreatedAt, button.ExpiresAt)
if err != nil {
return fmt.Errorf("save requested webview button: %w", err)
}
@ -435,18 +448,28 @@ ON CONFLICT (webapp_req_id) DO UPDATE SET
func (s *BotStore) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) {
_, _ = s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE expires_at <= now()`)
var button domain.BotRequestedWebViewButton
var peerFilter []byte
err := s.db.QueryRow(ctx, `
SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at
SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity,
peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at
FROM webview_requested_buttons
WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3 AND expires_at > now()`,
botUserID, userID, webAppReqID).
Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID, &button.Text, &button.PeerType, &button.MaxQuantity, &button.CreatedAt, &button.ExpiresAt)
Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID,
&button.Text, &button.PeerType, &button.MaxQuantity, &peerFilter,
&button.NameRequested, &button.UsernameRequested, &button.PhotoRequested,
&button.CreatedAt, &button.ExpiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.BotRequestedWebViewButton{}, false, nil
}
return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("get requested webview button: %w", err)
}
if string(peerFilter) != "{}" && string(peerFilter) != "null" {
if err := json.Unmarshal(peerFilter, &button.PeerFilter); err != nil {
return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("decode requested webview button filter: %w", err)
}
}
return button, true, nil
}

View file

@ -1,8 +1,11 @@
package postgres
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"time"
"github.com/jackc/pgx/v5"
@ -20,17 +23,331 @@ func NewBotAPIUpdateStore(db sqlcgen.DBTX) *BotAPIUpdateStore {
return &BotAPIUpdateStore{db: db}
}
func (s *BotAPIUpdateStore) SetBotAPIWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error {
if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 {
return fmt.Errorf("invalid bot api webhook")
}
var allowed []string
if len(config.AllowedUpdates) > 0 {
allowed = make([]string, 0, len(config.AllowedUpdates))
for _, kind := range config.AllowedUpdates {
if kind != "" {
allowed = append(allowed, string(kind))
}
}
}
if _, err := s.db.Exec(ctx, `
WITH policy AS (
SELECT CASE WHEN $6::boolean THEN $5::text[]
ELSE (SELECT allowed_updates FROM bot_api_update_states WHERE bot_user_id = $1)
END AS allowed_updates
), configured AS (
INSERT INTO bot_api_webhooks (
bot_user_id, url, secret_token, max_connections, allowed_updates,
failure_count, last_error_date, last_error_message, next_attempt_at,
delivery_owner, delivery_expires_at, updated_at
)
SELECT $1, $2, $3, $4, allowed_updates, 0, 0, '', now(), '', NULL, now()
FROM policy
ON CONFLICT (bot_user_id) DO UPDATE
SET url = EXCLUDED.url,
secret_token = EXCLUDED.secret_token,
max_connections = EXCLUDED.max_connections,
allowed_updates = EXCLUDED.allowed_updates,
failure_count = 0,
last_error_date = 0,
last_error_message = '',
next_attempt_at = now(),
delivery_owner = '',
delivery_expires_at = NULL,
updated_at = now()
RETURNING bot_user_id
), boundary AS (
SELECT CASE WHEN $7::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id
FROM bot_api_updates
WHERE bot_user_id = $1
)
INSERT INTO bot_api_update_states (
bot_user_id, confirmed_update_id, allowed_updates, cursor_initialized
)
SELECT $1, confirmed_update_id, policy.allowed_updates, $7::boolean
FROM boundary, configured, policy
ON CONFLICT (bot_user_id) DO UPDATE
SET confirmed_update_id = CASE WHEN $7::boolean
THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id)
ELSE bot_api_update_states.confirmed_update_id
END,
allowed_updates = EXCLUDED.allowed_updates,
cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized,
updated_at = now()
`, config.BotUserID, config.URL, config.SecretToken, config.MaxConnections, allowed,
config.AllowedUpdatesSet, dropPending); err != nil {
return fmt.Errorf("set bot api webhook: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(ctx context.Context, botUserID int64, dropPending bool) error {
if botUserID <= 0 {
return nil
}
if _, err := s.db.Exec(ctx, `
WITH deleted AS (
DELETE FROM bot_api_webhooks WHERE bot_user_id = $1 RETURNING bot_user_id
), boundary AS (
SELECT CASE WHEN $2::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id
FROM bot_api_updates
WHERE bot_user_id = $1
)
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
SELECT $1, confirmed_update_id, $2::boolean
FROM boundary
ON CONFLICT (bot_user_id) DO UPDATE
SET confirmed_update_id = CASE WHEN $2::boolean
THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id)
ELSE bot_api_update_states.confirmed_update_id
END,
cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized,
updated_at = now()
`, botUserID, dropPending); err != nil {
return fmt.Errorf("delete bot api webhook: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) BotAPIWebhook(ctx context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) {
config, err := scanBotAPIWebhook(s.db.QueryRow(ctx, `
SELECT bot_user_id, url, secret_token, max_connections, allowed_updates,
failure_count, last_error_date, last_error_message, next_attempt_at
FROM bot_api_webhooks
WHERE bot_user_id = $1
`, botUserID))
if err == pgx.ErrNoRows {
return domain.BotAPIWebhook{}, false, nil
}
if err != nil {
return domain.BotAPIWebhook{}, false, fmt.Errorf("get bot api webhook: %w", err)
}
return config, true, nil
}
func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) {
if limit <= 0 || limit > 1000 {
limit = 100
}
rows, err := s.db.Query(ctx, `
SELECT bot_user_id, url, secret_token, max_connections, allowed_updates,
failure_count, last_error_date, last_error_message, next_attempt_at
FROM bot_api_webhooks
WHERE next_attempt_at <= now()
AND (delivery_owner = '' OR delivery_expires_at <= now())
ORDER BY next_attempt_at, bot_user_id
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list due bot api webhooks: %w", err)
}
defer rows.Close()
out := make([]domain.BotAPIWebhook, 0, limit)
for rows.Next() {
config, err := scanBotAPIWebhook(rows)
if err != nil {
return nil, err
}
out = append(out, config)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list due bot api webhook rows: %w", err)
}
return out, nil
}
func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
if botUserID <= 0 || owner == "" || ttl <= 0 {
return false, fmt.Errorf("invalid bot api webhook lease")
}
var acquiredOwner string
err := s.db.QueryRow(ctx, `
UPDATE bot_api_webhooks
SET delivery_owner = $2,
delivery_expires_at = now() + make_interval(secs => $3),
updated_at = now()
WHERE bot_user_id = $1
AND (delivery_owner = $2 OR delivery_owner = '' OR delivery_expires_at <= now())
RETURNING delivery_owner
`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner)
if err == pgx.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("acquire bot api webhook lease: %w", err)
}
return acquiredOwner == owner, nil
}
func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string) error {
if botUserID <= 0 || owner == "" {
return nil
}
if _, err := s.db.Exec(ctx, `
UPDATE bot_api_webhooks
SET delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
WHERE bot_user_id = $1 AND delivery_owner = $2
`, botUserID, owner); err != nil {
return fmt.Errorf("release bot api webhook lease: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error {
if len(message) > 512 {
message = message[:512]
}
if _, err := s.db.Exec(ctx, `
UPDATE bot_api_webhooks
SET failure_count = failure_count + 1,
last_error_date = EXTRACT(EPOCH FROM now())::integer,
last_error_message = $3,
next_attempt_at = $4,
delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
WHERE bot_user_id = $1 AND delivery_owner = $2
`, botUserID, owner, message, nextAttempt); err != nil {
return fmt.Errorf("record bot api webhook failure: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time) error {
if _, err := s.db.Exec(ctx, `
UPDATE bot_api_webhooks
SET failure_count = 0, last_error_date = 0, last_error_message = '',
next_attempt_at = $3, delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
WHERE bot_user_id = $1 AND delivery_owner = $2
`, botUserID, owner, nextAttempt); err != nil {
return fmt.Errorf("record bot api webhook success: %w", err)
}
return nil
}
func scanBotAPIWebhook(row botAPIUpdateScanner) (domain.BotAPIWebhook, error) {
var config domain.BotAPIWebhook
var allowed []string
if err := row.Scan(&config.BotUserID, &config.URL, &config.SecretToken, &config.MaxConnections, &allowed,
&config.FailureCount, &config.LastErrorDate, &config.LastErrorMessage, &config.NextAttemptAt); err != nil {
return domain.BotAPIWebhook{}, err
}
if allowed != nil {
config.AllowedUpdates = make([]domain.BotAPIUpdateKind, 0, len(allowed))
for _, kind := range allowed {
config.AllowedUpdates = append(config.AllowedUpdates, domain.BotAPIUpdateKind(kind))
}
}
return config, nil
}
func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
if botUserID <= 0 || owner == "" || ttl <= 0 {
return false, fmt.Errorf("invalid bot api poll lease")
}
var acquiredOwner string
err := s.db.QueryRow(ctx, `
INSERT INTO bot_api_update_states (
bot_user_id, confirmed_update_id, poll_owner, poll_expires_at
) VALUES ($1, 0, $2, now() + make_interval(secs => $3))
ON CONFLICT (bot_user_id) DO UPDATE
SET poll_owner = EXCLUDED.poll_owner,
poll_expires_at = EXCLUDED.poll_expires_at,
updated_at = now()
WHERE bot_api_update_states.poll_owner = EXCLUDED.poll_owner
OR bot_api_update_states.poll_expires_at IS NULL
OR bot_api_update_states.poll_expires_at <= now()
RETURNING poll_owner
`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner)
if err == pgx.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("acquire bot api poll lease: %w", err)
}
return acquiredOwner == owner, nil
}
func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(ctx context.Context, botUserID int64, owner string) error {
if botUserID <= 0 || owner == "" {
return nil
}
if _, err := s.db.Exec(ctx, `
UPDATE bot_api_update_states
SET poll_owner = '', poll_expires_at = NULL, updated_at = now()
WHERE bot_user_id = $1 AND poll_owner = $2
`, botUserID, owner); err != nil {
return fmt.Errorf("release bot api poll lease: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) {
if err := validateBotAPIUpdateRequest(req); err != nil {
return domain.BotAPIUpdate{}, false, err
}
var callbackQueryID, callbackUserID, callbackChatInstance int64
var callbackInlineDCID, callbackInlineMessageID int
var callbackInlineOwnerID, callbackInlineAccessHash int64
var callbackData []byte
var ephemeralPayload []byte
if req.Callback != nil {
callbackQueryID = req.Callback.ID
callbackUserID = req.Callback.UserID
callbackChatInstance = req.Callback.ChatInstance
callbackData = req.Callback.Data
if req.Callback.InlineMessage != nil {
callbackInlineDCID = req.Callback.InlineMessage.DCID
callbackInlineOwnerID = req.Callback.InlineMessage.OwnerID
callbackInlineMessageID = req.Callback.InlineMessage.ID
callbackInlineAccessHash = req.Callback.InlineMessage.AccessHash
}
}
if req.Ephemeral != nil {
var err error
ephemeralPayload, err = json.Marshal(req.Ephemeral)
if err != nil {
return domain.BotAPIUpdate{}, false, fmt.Errorf("marshal bot api ephemeral payload: %w", err)
}
}
row, err := s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
INSERT INTO bot_api_updates (
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) DO NOTHING
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date))
WITH inserted AS (
INSERT INTO bot_api_updates (
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
ephemeral_payload
) SELECT $1, $2::varchar(32), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb
WHERE NOT EXISTS (
SELECT 1
FROM bot_api_update_states
WHERE bot_user_id = $1
AND allowed_updates IS NOT NULL
AND NOT ($2::text = ANY(allowed_updates))
)
ON CONFLICT DO NOTHING
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
ephemeral_payload
), wake_webhook AS (
UPDATE bot_api_webhooks
SET next_attempt_at = now(), updated_at = now()
WHERE bot_user_id = $1 AND EXISTS (SELECT 1 FROM inserted)
RETURNING bot_user_id
)
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
ephemeral_payload
FROM inserted
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date,
callbackQueryID, callbackUserID, callbackChatInstance, callbackData,
callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash, ephemeralPayload))
if err == nil {
return row, true, nil
}
@ -38,21 +355,75 @@ RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_p
return domain.BotAPIUpdate{}, false, fmt.Errorf("insert bot api update: %w", err)
}
row, err = s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
ephemeral_payload
FROM bot_api_updates
WHERE bot_user_id = $1
AND update_kind = $2
AND peer_type = $3
AND peer_id = $4
AND message_id = $5
AND source_pts = $6
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts))
AND (
(update_kind = 'callback_query' AND callback_query_id = $7)
OR
(update_kind <> 'callback_query' AND peer_type = $3 AND peer_id = $4 AND message_id = $5 AND (
(ephemeral_payload IS NULL AND $8::jsonb IS NULL AND source_pts = $6)
OR (ephemeral_payload = $8::jsonb)
))
)
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID, ephemeralPayload))
if err != nil {
if err == pgx.ErrNoRows {
return domain.BotAPIUpdate{}, false, nil
}
return domain.BotAPIUpdate{}, false, fmt.Errorf("select existing bot api update: %w", err)
}
return row, false, nil
}
func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(ctx context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) {
if botUserID == 0 || tail <= 0 {
return nil, nil
}
if limit <= 0 || limit > 100 {
limit = 100
}
rows, err := s.db.Query(ctx, `
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
ephemeral_payload
FROM (
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
ephemeral_payload
FROM bot_api_updates
WHERE bot_user_id = $1
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0)
ORDER BY id DESC
LIMIT $2
) AS tail_updates
ORDER BY id
LIMIT $3
`, botUserID, tail, limit)
if err != nil {
return nil, fmt.Errorf("list bot api tail updates: %w", err)
}
defer rows.Close()
out := make([]domain.BotAPIUpdate, 0, limit)
for rows.Next() {
item, err := scanBotAPIUpdateRows(rows)
if err != nil {
return nil, err
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list bot api tail update rows: %w", err)
}
return out, nil
}
func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) {
if botUserID == 0 {
return nil, nil
@ -64,7 +435,10 @@ func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fr
limit = 100
}
rows, err := s.db.Query(ctx, `
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
ephemeral_payload
FROM bot_api_updates
WHERE bot_user_id = $1 AND id >= $2
ORDER BY id
@ -93,23 +467,98 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(ctx context.Context, botUserID,
return nil
}
if _, err := s.db.Exec(ctx, `
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id)
VALUES ($1, $2)
WITH bounded AS (
SELECT COALESCE(MAX(id), 0) AS max_update_id,
LEAST($2::bigint, COALESCE(MAX(id), 0)) AS confirmed_update_id
FROM bot_api_updates
WHERE bot_user_id = $1
)
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
SELECT $1, confirmed_update_id, true
FROM bounded
ON CONFLICT (bot_user_id) DO UPDATE
SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id),
SET confirmed_update_id = GREATEST(
bot_api_update_states.confirmed_update_id,
CASE
WHEN $2::bigint > (SELECT max_update_id FROM bounded)
AND bot_api_update_states.cursor_initialized
THEN bot_api_update_states.confirmed_update_id
ELSE EXCLUDED.confirmed_update_id
END
),
cursor_initialized = true,
updated_at = now()
WHERE bot_api_update_states.confirmed_update_id < EXCLUDED.confirmed_update_id
`, botUserID, confirmedUpdateID); err != nil {
return fmt.Errorf("confirm bot api updates: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(ctx context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error {
if botUserID == 0 {
return nil
}
var values []string
if len(allowed) > 0 {
values = make([]string, 0, len(allowed))
for _, kind := range allowed {
if kind != "" {
values = append(values, string(kind))
}
}
}
if _, err := s.db.Exec(ctx, `
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, allowed_updates)
VALUES ($1, 0, $2::text[])
ON CONFLICT (bot_user_id) DO UPDATE
SET allowed_updates = EXCLUDED.allowed_updates,
updated_at = now()
`, botUserID, values); err != nil {
return fmt.Errorf("set bot api allowed updates: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error {
if botUserID == 0 {
return nil
}
if _, err := s.db.Exec(ctx, `
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
SELECT $1, COALESCE(MAX(id), 0), true
FROM bot_api_updates
WHERE bot_user_id = $1
ON CONFLICT (bot_user_id) DO UPDATE
SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id),
cursor_initialized = true,
updated_at = now()
`, botUserID); err != nil {
return fmt.Errorf("drop pending bot api updates: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(ctx context.Context, botUserID int64) (int, error) {
if botUserID == 0 {
return 0, nil
}
var count int
if err := s.db.QueryRow(ctx, `
SELECT COUNT(*)
FROM bot_api_updates
WHERE bot_user_id = $1
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0)
`, botUserID).Scan(&count); err != nil {
return 0, fmt.Errorf("count pending bot api updates: %w", err)
}
return count, nil
}
// DeleteDeliveredOrExpired 回收 Bot API 投递队列的死行(性能审计 H1
// 1. 已确认id <= bot_api_update_states.confirmed_update_id且入队超过 confirmedGrace 的行——
// 官方 Bot API 语义下确认即弃getUpdates 的 fromID 恒 > confirmed删除不影响任何读路径
// 宽限仅防御 offset 回拨调试场景。
// 2. 按消息 date 超过 maxAge 的行无论确认与否——对齐官方「updates 服务器最多保留 24 小时」
// 2. 按队列 created_at 超过 maxAge 的行无论确认与否——对齐官方「updates 服务器最多保留 24 小时」
// 语义,同时封顶 MTProto-only bot从不调 getUpdates、无 state 行)成员身份带来的无界增长。
//
// 与 user_update_events 的「永久保留」约束无关:那是 TDesktop 账号级 differenceTooLong 缺陷所迫,
@ -139,15 +588,15 @@ WHERE id IN (
total += int(tag.RowsAffected())
}
if maxAge > 0 {
cutoff := time.Now().Add(-maxAge).Unix()
// 走 bot_api_updates_retention_idx(date, id)。
cutoff := time.Now().Add(-maxAge)
// 走 bot_api_updates_created_retention_idx(created_at, id)。
tag, err := s.db.Exec(ctx, `
DELETE FROM bot_api_updates
WHERE id IN (
SELECT id
FROM bot_api_updates
WHERE date < $1
ORDER BY date, id
WHERE created_at < $1
ORDER BY created_at, id
LIMIT $2
)`, cutoff, limit)
if err != nil {
@ -187,28 +636,113 @@ type botAPIUpdateScanner interface {
func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error) {
var item domain.BotAPIUpdate
var kind, peerType string
if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date); err != nil {
var callbackQueryID, callbackUserID, callbackChatInstance int64
var callbackInlineDCID, callbackInlineMessageID int
var callbackInlineOwnerID, callbackInlineAccessHash int64
var callbackData []byte
var ephemeralPayload []byte
if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date,
&callbackQueryID, &callbackUserID, &callbackChatInstance, &callbackData,
&callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash,
&ephemeralPayload); err != nil {
return domain.BotAPIUpdate{}, err
}
item.Kind = domain.BotAPIUpdateKind(kind)
item.Peer.Type = domain.PeerType(peerType)
if item.Kind == domain.BotAPIUpdateCallbackQuery {
item.Callback = &domain.BotCallbackQuery{
ID: callbackQueryID,
BotUserID: item.BotUserID,
UserID: callbackUserID,
Peer: item.Peer,
MessageID: item.MessageID,
ChatInstance: callbackChatInstance,
Data: append([]byte(nil), callbackData...),
}
if callbackInlineMessageID > 0 {
item.Callback.InlineMessage = &domain.BotInlineMessageID{DCID: callbackInlineDCID, OwnerID: callbackInlineOwnerID, ID: callbackInlineMessageID, AccessHash: callbackInlineAccessHash}
}
}
if len(ephemeralPayload) != 0 {
var payload domain.BotAPIEphemeralPayload
decoder := json.NewDecoder(bytes.NewReader(ephemeralPayload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
return domain.BotAPIUpdate{}, fmt.Errorf("decode bot api ephemeral payload: %w", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return domain.BotAPIUpdate{}, fmt.Errorf("decode bot api ephemeral payload: trailing JSON")
}
if err := validateBotAPIEphemeralPayload(item.BotUserID, item.Kind, item.Peer, item.MessageID, item.SourcePts, item.Date, &payload); err != nil {
return domain.BotAPIUpdate{}, err
}
item.Ephemeral = &payload
}
return item, nil
}
func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
if req.BotUserID == 0 || req.MessageID <= 0 {
if req.BotUserID == 0 {
return fmt.Errorf("invalid bot api update")
}
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage {
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery {
return fmt.Errorf("invalid bot api update kind %q", req.Kind)
}
switch req.Peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
if req.Peer.ID <= 0 {
if req.Peer.ID <= 0 || req.MessageID <= 0 {
return fmt.Errorf("invalid bot api update peer")
}
case "":
if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 {
return fmt.Errorf("invalid bot api update peer")
}
default:
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type)
}
if err := validateBotAPIEphemeralPayload(req.BotUserID, req.Kind, req.Peer, req.MessageID, req.SourcePts, req.Date, req.Ephemeral); err != nil {
return err
}
if req.Kind == domain.BotAPIUpdateCallbackQuery {
cb := req.Callback
if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 ||
cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 ||
len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 {
return fmt.Errorf("invalid bot api callback query")
}
inline := cb.InlineMessage
if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) {
return fmt.Errorf("invalid bot api inline callback query")
}
if req.MessageID > 0 && inline != nil {
return fmt.Errorf("ambiguous bot api callback query")
}
} else if req.Callback != nil {
return fmt.Errorf("unexpected bot api callback query")
}
return nil
}
func validateBotAPIEphemeralPayload(botUserID int64, kind domain.BotAPIUpdateKind, peer domain.Peer, messageID, sourcePts, date int, payload *domain.BotAPIEphemeralPayload) error {
if payload == nil {
return nil
}
message := payload.Message
if payload.Validate() != nil || peer.Type != domain.PeerTypeChannel || message.ID != messageID || message.Peer != peer ||
message.Expired(time.Unix(int64(date), 0)) || sourcePts != 0 {
return fmt.Errorf("invalid bot api ephemeral update")
}
if kind == domain.BotAPIUpdateCallbackQuery {
if message.SenderUserID != botUserID {
return fmt.Errorf("invalid bot api ephemeral callback target")
}
return nil
}
if kind != domain.BotAPIUpdateMessage && kind != domain.BotAPIUpdateEditedMessage {
return fmt.Errorf("invalid bot api ephemeral update kind")
}
if message.ReceiverUserID != botUserID {
return fmt.Errorf("invalid bot api ephemeral receiver")
}
return nil
}

View file

@ -1,6 +1,7 @@
package postgres
import (
"bytes"
"context"
"testing"
"time"
@ -8,10 +9,292 @@ import (
"telesrv/internal/domain"
)
func TestBotAPICallbackQueryQueueRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
bot, err := users.Create(ctx, domain.User{
AccessHash: 921, Phone: "+1921" + suffix + "01", FirstName: "CallbackQueueBot",
})
if err != nil {
t.Fatalf("create bot user: %v", err)
}
clicker, err := users.Create(ctx, domain.User{
AccessHash: 922, Phone: "+1922" + suffix + "02", FirstName: "CallbackClicker",
})
if err != nil {
t.Fatalf("create callback user: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO bots (bot_user_id, owner_user_id, token_secret)
VALUES ($1, $1, 'callback-queue-secret')`, bot.ID); err != nil {
t.Fatalf("seed bot: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
})
callback := &domain.BotCallbackQuery{
ID: 880011, BotUserID: bot.ID, UserID: clicker.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: clicker.ID}, MessageID: 17,
ChatInstance: 990022, Data: []byte{0, 1, 0xff, 'x'},
}
req := domain.EnqueueBotAPIUpdateRequest{
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery,
Peer: callback.Peer, MessageID: callback.MessageID, Date: int(time.Now().Unix()), Callback: callback,
}
store := NewBotAPIUpdateStore(pool)
first, created, err := store.EnqueueBotAPIUpdate(ctx, req)
if err != nil || !created {
t.Fatalf("enqueue callback: row=%+v created=%v err=%v", first, created, err)
}
again, created, err := store.EnqueueBotAPIUpdate(ctx, req)
if err != nil || created || again.ID != first.ID {
t.Fatalf("dedupe callback: row=%+v created=%v err=%v", again, created, err)
}
items, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100)
if err != nil || len(items) != 1 {
t.Fatalf("list callback = %+v, %v", items, err)
}
got := items[0].Callback
if got == nil || got.ID != callback.ID || got.BotUserID != bot.ID || got.UserID != clicker.ID ||
got.Peer != callback.Peer || got.MessageID != callback.MessageID || got.ChatInstance != callback.ChatInstance ||
!bytes.Equal(got.Data, callback.Data) {
t.Fatalf("callback round trip = %+v, want %+v", got, callback)
}
}
func TestBotAPIInlineCallbackAndWebhookStateRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
bot, err := users.Create(ctx, domain.User{AccessHash: 931, Phone: "+1931" + suffix + "01", FirstName: "WebhookBot"})
if err != nil {
t.Fatal(err)
}
clicker, err := users.Create(ctx, domain.User{AccessHash: 932, Phone: "+1932" + suffix + "02", FirstName: "InlineClicker"})
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'webhook-secret')`, bot.ID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_webhooks WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
})
s := NewBotAPIUpdateStore(pool)
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 17, AccessHash: 445566}
callback := &domain.BotCallbackQuery{
ID: 9911, BotUserID: bot.ID, UserID: clicker.ID, ChatInstance: 8811,
Data: []byte{0, 1, 0xff}, InlineMessage: inline,
}
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: callback,
})
if err != nil || !created {
t.Fatalf("enqueue inline callback row=%#v created=%v err=%v", row, created, err)
}
items, err := s.ListBotAPIUpdates(ctx, bot.ID, row.ID, 100)
if err != nil || len(items) != 1 || items[0].Peer != (domain.Peer{}) || items[0].MessageID != 0 ||
items[0].Callback == nil || items[0].Callback.InlineMessage == nil || *items[0].Callback.InlineMessage != *inline ||
!bytes.Equal(items[0].Callback.Data, callback.Data) {
t.Fatalf("inline callback items=%#v err=%v", items, err)
}
config := domain.BotAPIWebhook{
BotUserID: bot.ID, URL: "https://example.test/hook", SecretToken: "safe_secret",
MaxConnections: 8, AllowedUpdates: []domain.BotAPIUpdateKind{domain.BotAPIUpdateCallbackQuery}, AllowedUpdatesSet: true,
}
if err := s.SetBotAPIWebhook(ctx, config, false); err != nil {
t.Fatal(err)
}
stored, found, err := s.BotAPIWebhook(ctx, bot.ID)
if err != nil || !found || stored.URL != config.URL || stored.SecretToken != config.SecretToken ||
stored.MaxConnections != 8 || len(stored.AllowedUpdates) != 1 {
t.Fatalf("webhook=%#v found=%v err=%v", stored, found, err)
}
config.URL = "https://example.test/reconfigured"
config.AllowedUpdates = nil
config.AllowedUpdatesSet = false
if err := s.SetBotAPIWebhook(ctx, config, false); err != nil {
t.Fatal(err)
}
stored, found, err = s.BotAPIWebhook(ctx, bot.ID)
if err != nil || !found || stored.URL != config.URL || len(stored.AllowedUpdates) != 1 || stored.AllowedUpdates[0] != domain.BotAPIUpdateCallbackQuery {
t.Fatalf("preserved webhook=%#v found=%v err=%v", stored, found, err)
}
if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired {
t.Fatalf("first lease=%v err=%v", acquired, err)
}
if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired {
t.Fatalf("second lease=%v err=%v", acquired, err)
}
if err := s.ReleaseBotAPIWebhookLease(ctx, bot.ID, "stale"); err != nil {
t.Fatal(err)
}
if acquired, _ := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); acquired {
t.Fatal("stale webhook release removed active lease")
}
next := time.Now().Add(time.Hour)
if err := s.RecordBotAPIWebhookSuccess(ctx, bot.ID, "one", next); err != nil {
t.Fatal(err)
}
if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 0 {
t.Fatalf("idle due=%#v err=%v", due, err)
}
// A newly inserted allowed callback wakes the idle webhook in the same SQL statement.
callback2 := *callback
callback2.ID++
callback2.InlineMessage = &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 18, AccessHash: 556677}
if _, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: &callback2,
}); err != nil || !created {
t.Fatalf("enqueue wake created=%v err=%v", created, err)
}
if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 1 || due[0].BotUserID != bot.ID {
t.Fatalf("woken due=%#v err=%v", due, err)
}
if err := s.DeleteBotAPIWebhook(ctx, bot.ID, true); err != nil {
t.Fatal(err)
}
if _, found, err := s.BotAPIWebhook(ctx, bot.ID); err != nil || found {
t.Fatalf("webhook after delete found=%v err=%v", found, err)
}
if pending, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || pending != 0 {
t.Fatalf("pending after delete/drop=%d err=%v", pending, err)
}
}
func TestBotAPIPollLeaseCrossStoreInstance(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
bot, err := users.Create(ctx, domain.User{AccessHash: 933, Phone: "+1933" + suffix + "01", FirstName: "PollLeaseBot"})
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-lease-secret')`, bot.ID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
})
a, b := NewBotAPIUpdateStore(pool), NewBotAPIUpdateStore(pool)
if acquired, err := a.AcquireBotAPIPollLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired {
t.Fatalf("first acquire=%v err=%v", acquired, err)
}
if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired {
t.Fatalf("cross-instance acquire=%v err=%v", acquired, err)
}
if err := b.ReleaseBotAPIPollLease(ctx, bot.ID, "stale"); err != nil {
t.Fatal(err)
}
if acquired, _ := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); acquired {
t.Fatal("stale release removed active poll lease")
}
if err := a.ReleaseBotAPIPollLease(ctx, bot.ID, "one"); err != nil {
t.Fatal(err)
}
if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || !acquired {
t.Fatalf("successor acquire=%v err=%v", acquired, err)
}
}
func TestBotAPIPollingStateClampFilterTailAndDrop(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
bot, err := users.Create(ctx, domain.User{
AccessHash: 923, Phone: "+1923" + suffix + "01", FirstName: "PollingStateBot",
})
if err != nil {
t.Fatalf("create bot user: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-state-secret')`, bot.ID); err != nil {
t.Fatalf("seed bot: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
})
s := NewBotAPIUpdateStore(pool)
enqueue := func(kind domain.BotAPIUpdateKind, messageID int) (domain.BotAPIUpdate, bool) {
t.Helper()
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: bot.ID, Kind: kind,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID + 1},
MessageID: messageID, SourcePts: messageID, Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("enqueue %s/%d: %v", kind, messageID, err)
}
return row, created
}
for id := 1; id <= 3; id++ {
if _, created := enqueue(domain.BotAPIUpdateMessage, id); !created {
t.Fatalf("initial message %d was not created", id)
}
}
if err := s.SetBotAPIAllowedUpdates(ctx, bot.ID, []domain.BotAPIUpdateKind{domain.BotAPIUpdateEditedMessage}); err != nil {
t.Fatalf("set allowed updates: %v", err)
}
if row, created := enqueue(domain.BotAPIUpdateMessage, 4); created || row.ID != 0 {
t.Fatalf("filtered row=%+v created=%v", row, created)
}
lastBeforeBaseline, created := enqueue(domain.BotAPIUpdateEditedMessage, 5)
if !created {
t.Fatal("allowed edit was filtered")
}
if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil {
t.Fatalf("initialize external cursor: %v", err)
}
confirmed, found, err := s.ConfirmedBotAPIUpdateID(ctx, bot.ID)
if err != nil || !found || confirmed != lastBeforeBaseline.ID {
t.Fatalf("baseline confirmed=%d found=%v err=%v want=%d", confirmed, found, err, lastBeforeBaseline.ID)
}
pendingRow, created := enqueue(domain.BotAPIUpdateEditedMessage, 6)
if !created {
t.Fatal("post-baseline edit was filtered")
}
if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil {
t.Fatalf("repeat external cursor: %v", err)
}
confirmed, _, _ = s.ConfirmedBotAPIUpdateID(ctx, bot.ID)
if confirmed != lastBeforeBaseline.ID {
t.Fatalf("repeat external cursor advanced to %d, want %d", confirmed, lastBeforeBaseline.ID)
}
tail, err := s.ListTailBotAPIUpdates(ctx, bot.ID, 1, 100)
if err != nil || len(tail) != 1 || tail[0].ID != pendingRow.ID {
t.Fatalf("tail=%+v err=%v want=%d", tail, err, pendingRow.ID)
}
if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 1 {
t.Fatalf("pending count=%d err=%v", count, err)
}
if err := s.DropPendingBotAPIUpdates(ctx, bot.ID); err != nil {
t.Fatalf("drop pending: %v", err)
}
if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 0 {
t.Fatalf("pending after drop=%d err=%v", count, err)
}
}
// TestBotAPIUpdateRetention 锁定 H1 场景矩阵:
// - 已确认 + 超宽限 → 删;已确认 + 宽限内 → 留;
// - 未确认 + date 超保留期 → 删(含无 state 行的 MTProto-only bot
// - 未确认 + date 在保留期内 → 留;
// - 未确认 + created_at 超保留期 → 删(含无 state 行的 MTProto-only bot
// - 未确认 + created_at 在保留期内 → 留;
// - 删除后 getUpdates 读路径fromID > confirmed不受影响。
func TestBotAPIUpdateRetention(t *testing.T) {
pool := testPool(t)
@ -47,7 +330,6 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
s := NewBotAPIUpdateStore(pool)
now := time.Now().Unix()
stale := now - int64((48 * time.Hour).Seconds())
enqueue := func(botID int64, messageID int, date int64) domain.BotAPIUpdate {
t.Helper()
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
@ -67,7 +349,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
confirmedOld := enqueue(confirmedBot, 1, now) // 已确认 + created_at 回拨超宽限 → 删
confirmedFresh := enqueue(confirmedBot, 2, now) // 已确认 + 宽限内 → 留
unconfirmedFresh := enqueue(confirmedBot, 3, now)
expiredNoState := enqueue(mtprotoOnlyBot, 4, stale) // 无 state 行 + date 超保留期 → 删
expiredNoState := enqueue(mtprotoOnlyBot, 4, now) // 无 state 行 + created_at 超保留期 → 删
freshNoState := enqueue(mtprotoOnlyBot, 5, now)
if err := s.ConfirmBotAPIUpdates(ctx, confirmedBot, confirmedFresh.ID); err != nil {
@ -77,6 +359,10 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
"UPDATE bot_api_updates SET created_at = now() - interval '1 hour' WHERE id = $1", confirmedOld.ID); err != nil {
t.Fatalf("backdate confirmed row: %v", err)
}
if _, err := pool.Exec(ctx,
"UPDATE bot_api_updates SET created_at = now() - interval '48 hours' WHERE id = $1", expiredNoState.ID); err != nil {
t.Fatalf("backdate expired row: %v", err)
}
deleted, err := s.DeleteDeliveredOrExpired(ctx, 15*time.Minute, 24*time.Hour, 1000)
if err != nil {
@ -85,7 +371,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
// 共享测试库可能有其它历史行同被回收,只要求至少删掉本测试的 2 行;
// 精确归属由下方 remaining 断言保证。
if deleted < 2 {
t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, date expired)", deleted)
t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, created_at expired)", deleted)
}
remaining := map[int64]bool{}
@ -120,3 +406,70 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
t.Fatalf("post-retention list = %+v, want only unconfirmed fresh row %d", items, unconfirmedFresh.ID)
}
}
func TestBotAPIEphemeralEnvelopeRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
bot, err := users.Create(ctx, domain.User{AccessHash: 941, Phone: "+1941" + suffix + "01", FirstName: "EphemeralQueueBot"})
if err != nil {
t.Fatal(err)
}
human, err := users.Create(ctx, domain.User{AccessHash: 942, Phone: "+1942" + suffix + "02", FirstName: "EphemeralHuman"})
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'ephemeral-secret')`, bot.ID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
})
now := time.Now()
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 3901}
message := domain.EphemeralMessage{
ID: 81, Peer: peer, SenderUserID: human.ID, ReceiverUserID: bot.ID,
Date: int(now.Unix()), RandomID: 11, Content: domain.EphemeralContent{Message: "/private"},
Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
store := NewBotAPIUpdateStore(pool)
request := domain.EnqueueBotAPIUpdateRequest{
BotUserID: bot.ID, Kind: domain.BotAPIUpdateMessage, Peer: peer,
MessageID: message.ID, Date: message.Date,
Ephemeral: domain.NewBotAPIEphemeralPayload(message),
}
first, created, err := store.EnqueueBotAPIUpdate(ctx, request)
if err != nil || !created || first.Ephemeral == nil {
t.Fatalf("first=%+v created=%v err=%v", first, created, err)
}
var leakedPrivateRoutingState bool
if err := pool.QueryRow(ctx, `
SELECT (ephemeral_payload -> 'Message') ?| ARRAY[
'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted'
]
FROM bot_api_updates WHERE id = $1`, first.ID).Scan(&leakedPrivateRoutingState); err != nil {
t.Fatal(err)
}
if leakedPrivateRoutingState {
t.Fatal("durable Bot API envelope contains private ephemeral routing fields")
}
if replay, created, err := store.EnqueueBotAPIUpdate(ctx, request); err != nil || created || replay.ID != first.ID {
t.Fatalf("replay=%+v created=%v err=%v", replay, created, err)
}
message.Version, message.EditDate, message.Content.Message = 2, message.Date+1, "edited"
request.Kind = domain.BotAPIUpdateEditedMessage
request.Ephemeral = domain.NewBotAPIEphemeralPayload(message)
second, created, err := store.EnqueueBotAPIUpdate(ctx, request)
if err != nil || !created || second.ID <= first.ID {
t.Fatalf("second=%+v created=%v err=%v", second, created, err)
}
rows, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100)
if err != nil || len(rows) != 2 || rows[0].SourcePts != 0 || rows[0].Ephemeral == nil ||
rows[0].Ephemeral.Message.Content.Message != "/private" || rows[1].Ephemeral.Message.Content.Message != "edited" {
t.Fatalf("rows=%+v err=%v", rows, err)
}
}

View file

@ -377,6 +377,20 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
if err != nil {
return nil, err
}
parentIDs := make([]int64, 0, len(channels))
for _, channel := range channels {
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
parentIDs = append(parentIDs, channel.LinkedMonoforumID)
}
}
parents, err := listChannelsByIDs(ctx, s.db, parentIDs)
if err != nil {
return nil, err
}
parentsByID := make(map[int64]domain.Channel, len(parents))
for _, parent := range parents {
parentsByID[parent.ID] = parent
}
linkedGuests, err := s.listLinkedDiscussionGuests(ctx, s.db, viewerUserID, remaining)
if err != nil {
return nil, err
@ -402,6 +416,17 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
}
continue
}
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
if parent, ok := parentsByID[channel.LinkedMonoforumID]; ok && parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == channel.ID {
member := syntheticMonoforumUserMember(channel, viewerUserID)
views[channel.ID] = domain.ChannelView{
Channel: channel,
Self: member,
Dialog: previewChannelDialog(viewerUserID, channel, member),
}
continue
}
}
if !publicPreviewableChannel(channel) {
continue
}
@ -472,7 +497,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.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.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights,
&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,
&ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted,

View file

@ -4,6 +4,8 @@ import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
@ -53,6 +55,22 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se
_ = tx.Rollback(ctx)
}
}()
if err := s.appendStarGiftAdminLogTx(ctx, tx, channelID, senderUserID, savedID, date, action); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit star gift admin log: %w", err)
}
committed = true
return nil
}
// appendStarGiftAdminLogTx is the aggregate-local form used when the saved gift,
// inventory/balance mutation and Recent Actions entry must commit together.
func (s *ChannelStore) appendStarGiftAdminLogTx(ctx context.Context, tx pgx.Tx, channelID, senderUserID, savedID int64, date int, action domain.ChannelMessageAction) error {
if channelID == 0 || senderUserID == 0 || savedID <= 0 {
return domain.ErrChannelInvalid
}
channel, err := getChannelByID(ctx, tx, channelID)
if err != nil {
return err
@ -63,29 +81,14 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se
}
action = channelServiceActionForMessage(channelID, messageID, action)
msg := domain.ChannelMessage{
ChannelID: channelID,
ID: messageID,
SenderUserID: senderUserID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID},
Date: date,
Post: channel.Broadcast,
Action: &action,
Pts: channel.Pts,
ChannelID: channelID, ID: messageID, SenderUserID: senderUserID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, Date: date,
Post: channel.Broadcast, Action: &action, Pts: channel.Pts,
}
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
ChannelID: channelID,
UserID: senderUserID,
Date: date,
Type: domain.ChannelAdminLogSendMessage,
Message: &msg,
}); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit star gift admin log: %w", err)
}
committed = true
return nil
return s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
ChannelID: channelID, UserID: senderUserID, Date: date,
Type: domain.ChannelAdminLogSendMessage, Message: &msg,
})
}
func (s *ChannelStore) appendServiceMessage(ctx context.Context, label string, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) {

View file

@ -298,12 +298,26 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(ctx context.Context, userID,
limit = domain.MaxSynchronousChannelDialogFanout
}
rows, err := s.db.Query(ctx, `
SELECT channel_id
FROM user_channel_member_index
WHERE user_id = $1
AND status = 'active'
AND NOT deleted
AND channel_id > $2
WITH visible_channels AS (
SELECT channel_id
FROM user_channel_member_index
WHERE user_id = $1 AND status = 'active' AND NOT deleted
UNION
SELECT mono.id
FROM channels mono
JOIN channels parent ON parent.id = mono.linked_monoforum_id
AND NOT parent.deleted AND parent.broadcast_messages_allowed AND parent.linked_monoforum_id = mono.id
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')
) 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
))
)
SELECT channel_id FROM visible_channels
WHERE channel_id > $2
ORDER BY channel_id
LIMIT $3`, userID, afterChannelID, limit)
if err != nil {
@ -329,16 +343,31 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(ctx context.Context, userI
limit = domain.MaxChannelDifferenceLimit
}
rows, err := s.db.Query(ctx, `
SELECT i.channel_id, c.pts
FROM user_channel_member_index i
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
JOIN channel_update_checkpoints cp ON cp.channel_id = i.channel_id
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND i.channel_id > $3
WITH visible_channels AS (
SELECT channel_id
FROM user_channel_member_index
WHERE user_id = $1 AND status = 'active' AND NOT deleted
UNION
SELECT mono.id
FROM channels mono
JOIN channels parent ON parent.id = mono.linked_monoforum_id
AND NOT parent.deleted AND parent.broadcast_messages_allowed AND parent.linked_monoforum_id = mono.id
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')
) 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
))
)
SELECT visible.channel_id, c.pts
FROM visible_channels visible
JOIN channels c ON c.id = visible.channel_id AND NOT c.deleted
JOIN channel_update_checkpoints cp ON cp.channel_id = visible.channel_id
WHERE visible.channel_id > $3
AND cp.latest_event_date > $2
ORDER BY i.channel_id ASC
ORDER BY visible.channel_id ASC
LIMIT $4`, userID, sinceDate, afterChannelID, limit)
if err != nil {
return nil, fmt.Errorf("list dirty active channels for user: %w", err)
@ -381,6 +410,15 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX,
} else if ok {
return ch, member, true, nil
}
if ch.Monoforum && ch.LinkedMonoforumID != 0 {
parent, parentErr := s.channelByID(ctx, db, ch.LinkedMonoforumID)
if parentErr != nil {
return domain.Channel{}, domain.ChannelMember{}, false, parentErr
}
if parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == ch.ID {
return ch, syntheticMonoforumUserMember(ch, viewerUserID), true, nil
}
}
if !publicPreviewableChannel(ch) {
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
}

View file

@ -29,6 +29,9 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
if err != nil {
return domain.CreateChannelResult{}, err
}
if channel.Monoforum {
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
}
if !canInviteToChannel(channel, inviter) {
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired
}

View file

@ -544,6 +544,40 @@ LIMIT $2`, userID, domain.MaxAdminedPublicChannels)
return out, nil
}
func (s *ChannelStore) ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
if userID == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, `
SELECT i.channel_id
FROM user_channel_member_index i
JOIN channels c ON c.id=i.channel_id
WHERE i.user_id=$1
AND i.status='active'
AND i.role IN ('creator','admin')
AND NOT i.deleted
AND NOT c.monoforum
AND c.linked_community_id=0
ORDER BY i.channel_id DESC
LIMIT $2`, userID, domain.MaxCommunityPeers)
if err != nil {
return nil, fmt.Errorf("list community-linkable channels: %w", err)
}
defer rows.Close()
ids := make([]int64, 0, domain.MaxCommunityPeers)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return listChannelsByIDsInOrder(ctx, s.db, ids)
}
// ListSendAsChannels lists the broadcast channels a user may post messages AS in groups
// (channels.getSendAs candidates): channels where the user is the creator, or an admin holding
// PostMessages rights. Mirrors ListStoryPostableChannels but is restricted to broadcast channels

View file

@ -478,6 +478,15 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
return member
}
func syntheticMonoforumUserMember(mono domain.Channel, userID int64) domain.ChannelMember {
return domain.ChannelMember{
ChannelID: mono.ID,
UserID: userID,
Role: domain.ChannelRoleMember,
Status: domain.ChannelMemberActive,
}
}
func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool {
return rights == domain.ChannelAdminRights{}
}

View file

@ -33,6 +33,9 @@ func (s *ChannelStore) JoinChannel(ctx context.Context, channelID, userID int64,
if err != nil {
return domain.CreateChannelResult{}, err
}
if channel.Monoforum {
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
}
existing, existingErr := s.getChannelMember(ctx, tx, channelID, userID)
if existingErr == nil {
switch {

View file

@ -33,17 +33,19 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
var richMessageJSON string
var savedPeerType string
var savedPeerID int64
var suggestedPostJSON string
if err := row.Scan(
&msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID,
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON,
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID,
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &msg.PaidMessageStars, &suggestedPostJSON,
); err != nil {
return domain.ChannelMessage{}, err
}
msg.From.Type = domain.PeerType(fromType)
msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID}
msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON)
if sendAsType.Valid && sendAsID.Valid {
msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64}
}
@ -90,17 +92,19 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er
var richMessageJSON string
var savedPeerType string
var savedPeerID int64
var suggestedPostJSON string
if err := row.Scan(
&msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID,
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON,
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count,
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &msg.PaidMessageStars, &suggestedPostJSON, &count,
); err != nil {
return domain.ChannelMessage{}, 0, err
}
msg.From.Type = domain.PeerType(fromType)
msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID}
msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON)
if sendAsType.Valid && sendAsID.Valid {
msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64}
}

View file

@ -25,7 +25,12 @@ func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int6
base := "channel_id = $1 AND NOT deleted"
extraChannels := []domain.Channel(nil)
if channel.Monoforum {
base += " AND saved_peer_id = 0"
if isChannelAdmin(member) {
base += " AND saved_peer_id = 0"
} else {
baseArgs = append(baseArgs, viewerUserID)
base += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(baseArgs))
}
if channel.LinkedMonoforumID != 0 {
if parent, parentErr := s.channelByID(ctx, s.db, channel.LinkedMonoforumID); parentErr == nil {
extraChannels = append(extraChannels, parent)
@ -216,8 +221,12 @@ func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID in
if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit {
limit = domain.MaxChannelGlobalSearchLimit
}
args := []any{viewerUserID}
args := []any{viewerUserID, req.AllowPublicPreview}
where := `NOT deleted`
if req.RestrictChannelIDs {
args = append(args, req.ChannelIDs)
where += fmt.Sprintf("\nAND channel_id = ANY($%d::bigint[])", len(args))
}
if query != "" {
args = append(args, "%"+escapeLike(query)+"%")
where += fmt.Sprintf(`
@ -237,15 +246,18 @@ AND EXISTS (
where += `
AND EXISTS (
SELECT 1
FROM channels c
JOIN channel_members cm ON cm.channel_id = c.id
AND cm.user_id = $1
AND cm.status = 'active'
AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false)
FROM channels c
LEFT JOIN channel_members cm ON cm.channel_id = c.id AND cm.user_id = $1
LEFT JOIN channel_dialogs d ON d.channel_id = c.id AND d.user_id = $1
WHERE c.id = channel_messages.channel_id
AND NOT c.deleted
AND (cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)`
WHERE c.id = channel_messages.channel_id
AND NOT c.deleted
AND (
(cm.status = 'active' AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false))
OR ($2::boolean AND COALESCE(c.username,'') <> ''
AND COALESCE(cm.status,'') <> 'kicked'
AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false))
)
AND (COALESCE(cm.status,'') <> 'active' OR cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)`
if req.BroadcastsOnly {
where += `
AND c.broadcast AND NOT c.megagroup`

View file

@ -470,7 +470,16 @@ WHERE channel_id = $1 AND id = $2`, msg.ChannelID, msg.ID).Scan(
Message: replay,
SenderUserID: first.SenderUserID,
}
return domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete}, nil
result := domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete}
if first.PaidMessageStars > 0 {
balance := domain.StarsBalance{UserID: first.SenderUserID}
if err := s.db.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1`, first.SenderUserID).
Scan(&balance.Balance, &balance.Granted); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("load paid-message replay balance: %w", err)
}
result.SenderStarsBalance = &balance
}
return result, nil
}
func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, channel domain.Channel, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) {
@ -578,6 +587,10 @@ func insertChannelMessageWithFingerprintTx(ctx context.Context, tx pgx.Tx, msg d
if err != nil {
return err
}
suggestedPost, err := marshalJSON(msg.SuggestedPost, "{}")
if err != nil {
return err
}
sendSnapshot := []byte("{}")
if msg.RandomID != 0 {
sendSnapshot, err = store.EncodeChannelSendSnapshot(msg)
@ -613,12 +626,12 @@ INSERT INTO channel_messages (
channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id,
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards,
body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id, send_snapshot, request_fingerprint
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38::jsonb,$39::bytea)`,
fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id, paid_message_stars, suggested_post, send_snapshot, request_fingerprint
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39::jsonb,$40::jsonb,$41::bytea)`,
msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID,
sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards,
msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID,
forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID, sendSnapshot, requestFingerprint); err != nil {
forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID, msg.PaidMessageStars, suggestedPost, sendSnapshot, requestFingerprint); err != nil {
return fmt.Errorf("insert channel message: %w", err)
}
// 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。

View file

@ -12,14 +12,19 @@ import (
"telesrv/internal/store"
)
const paidMessageChannelCommissionPermille int64 = 850
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
// 私信消息存进 channel_messages(复用 channel pts/事件/difference);发件权限(订阅者身份/管理员)
// 由 RPC 层校验,store 只校验 monoforum 频道存在,不要求发件人是成员(订阅者不是 monoforum 成员)
// 私信消息存进 channel_messages(复用 channel pts/事件/difference);store 在写边界再次强制:订阅者
// 无需成员记录但只能写自己的 saved_peer母频道管理员可以回复任意订阅者
func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) {
if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 ||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" {
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
if req.AllowPaidStars < 0 {
return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount
}
requestFingerprint, err := store.MonoforumSendFingerprint(req)
if err != nil {
return domain.SendChannelMessageResult{}, err
@ -65,6 +70,101 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
if channel.Deleted || !channel.Monoforum {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
parent, err := getChannelByID(ctx, tx, channel.LinkedMonoforumID)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
var monoDeleted, parentDeleted, directEnabled bool
var linkedMonoforumID, monoPrice, parentPrice int64
if err := tx.QueryRow(ctx, `
SELECT m.deleted, p.deleted, p.broadcast_messages_allowed, p.linked_monoforum_id,
m.send_paid_messages_stars, p.send_paid_messages_stars
FROM channels m
JOIN channels p ON p.id = m.linked_monoforum_id
WHERE m.id = $1
FOR SHARE OF m, p`, channel.ID).Scan(
&monoDeleted, &parentDeleted, &directEnabled, &linkedMonoforumID, &monoPrice, &parentPrice,
); err != nil {
return domain.SendChannelMessageResult{}, err
}
if monoDeleted || parentDeleted || !directEnabled || linkedMonoforumID != channel.ID {
return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate
}
if monoPrice != parentPrice || monoPrice < 0 {
return domain.SendChannelMessageResult{}, fmt.Errorf("monoforum %d paid-message price disagrees with parent %d", channel.ID, parent.ID)
}
channel.SendPaidMessagesStars = monoPrice
parent.SendPaidMessagesStars = parentPrice
parentMember, parentMemberErr := s.getChannelMember(ctx, tx, parent.ID, req.SenderUserID)
if parentMemberErr != nil && !errors.Is(parentMemberErr, domain.ErrChannelPrivate) {
return domain.SendChannelMessageResult{}, parentMemberErr
}
isAdmin := parentMemberErr == nil && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember)
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
}
var senderBalance *domain.StarsBalance
paidMessageStars := int64(0)
if !isAdmin && channel.SendPaidMessagesStars > 0 {
if req.AllowPaidStars < channel.SendPaidMessagesStars {
return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars}
}
balance := domain.StarsBalance{UserID: req.SenderUserID}
if err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, req.SenderUserID).
Scan(&balance.Balance, &balance.Granted); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
}
return domain.SendChannelMessageResult{}, fmt.Errorf("lock paid-message sender balance: %w", err)
}
if balance.Balance < channel.SendPaidMessagesStars {
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
}
paidMessageStars = channel.SendPaidMessagesStars
if err := tx.QueryRow(ctx, `
UPDATE stars_balances
SET balance = balance - $2, updated_at = now()
WHERE user_id = $1
RETURNING balance`, req.SenderUserID, paidMessageStars).Scan(&balance.Balance); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("debit paid-message sender balance: %w", err)
}
if err := insertStarsTxn(ctx, tx, req.SenderUserID, -paidMessageStars, domain.StarsReasonPaidMessage,
domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, req.Date, "Paid message", ""); err != nil {
return domain.SendChannelMessageResult{}, err
}
channelCredit := paidMessageStars * paidMessageChannelCommissionPermille / 1000
if channelCredit > 0 {
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()`, parent.ID, channelCredit); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("credit paid-message channel balance: %w", err)
}
}
senderBalance = &balance
}
if req.ReplyTo != nil {
if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) {
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
}
var exists bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM channel_messages
WHERE channel_id = $1 AND id = $2 AND NOT deleted
AND saved_peer_type = $3 AND saved_peer_id = $4
)`, channel.ID, req.ReplyTo.MessageID, string(req.SavedPeer.Type), req.SavedPeer.ID).Scan(&exists); err != nil {
return domain.SendChannelMessageResult{}, err
}
if !exists {
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
}
}
from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
if isAdmin {
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
}
msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.MonoforumID)
if err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum message id: %w", err)
@ -74,16 +174,22 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum pts: %w", err)
}
msg := domain.ChannelMessage{
ChannelID: req.MonoforumID,
ID: msgID,
RandomID: req.RandomID,
SenderUserID: req.SenderUserID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID},
SavedPeer: req.SavedPeer,
Date: req.Date,
Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...),
Pts: pts,
ChannelID: req.MonoforumID,
ID: msgID,
RandomID: req.RandomID,
SenderUserID: req.SenderUserID,
From: from,
SavedPeer: req.SavedPeer,
SuggestedPost: req.SuggestedPost,
PaidMessageStars: paidMessageStars,
Date: req.Date,
Silent: req.Silent,
NoForwards: req.NoForwards,
Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media,
ReplyTo: req.ReplyTo,
Pts: pts,
}
event := domain.ChannelUpdateEvent{
ChannelID: req.MonoforumID,
@ -130,13 +236,31 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.MonoforumID, msgID, pts); err != nil {
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)
if err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("list monoforum recipients: %w", err)
}
for rows.Next() {
var recipient int64
if err := rows.Scan(&recipient); err != nil {
rows.Close()
return domain.SendChannelMessageResult{}, err
}
recipients = append(recipients, recipient)
}
if err := rows.Err(); err != nil {
rows.Close()
return domain.SendChannelMessageResult{}, err
}
rows.Close()
if err := tx.Commit(ctx); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("commit send monoforum: %w", err)
}
committed = true
channel.TopMessageID = msgID
channel.Pts = pts
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event}, nil
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: uniqueChannelUserIDs(recipients, 0), SenderStarsBalance: senderBalance}, nil
}
// ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。
@ -205,9 +329,11 @@ func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, m
return domain.Channel{}, false, domain.ErrChannelInvalid
}
isAdmin := false
if _, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); err == nil {
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)
} else if !errors.Is(memberErr, domain.ErrChannelPrivate) {
return domain.Channel{}, false, memberErr
}
return mono, isAdmin, nil
}

View file

@ -3,6 +3,7 @@ package postgres
import (
"context"
"errors"
"slices"
"testing"
"telesrv/internal/domain"
@ -55,18 +56,46 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
channelIDs = append(channelIDs, monoID)
subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID}
if _, err := channels.GetChannel(ctx, sub.ID, monoID); err != nil {
t.Fatalf("subscriber get enabled monoforum without membership: %v", err)
}
if _, err := channels.JoinChannel(ctx, monoID, sub.ID, 1700001001); !errors.Is(err, domain.ErrChannelMonoforumUnsupported) {
t.Fatalf("subscriber join monoforum err = %v, want ErrChannelMonoforumUnsupported", err)
}
suggestedDraft := domain.DialogDraft{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, Message: "pending suggested post", Date: 1700001001,
SuggestedPost: &domain.SuggestedPost{
Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10},
ScheduleDate: 1700100000,
},
}
dialogStore := NewDialogStore(pool)
if err := dialogStore.SaveDraft(ctx, sub.ID, suggestedDraft); err != nil {
t.Fatalf("save subscriber monoforum draft: %v", err)
}
loadedDraft, found, err := dialogStore.GetDraft(ctx, sub.ID, suggestedDraft.Peer, 0)
if err != nil || !found || loadedDraft.SuggestedPost == nil || loadedDraft.SuggestedPost.Price == nil || loadedDraft.SuggestedPost.Price.Amount != 10 || loadedDraft.SuggestedPost.ScheduleDate != 1700100000 {
t.Fatalf("loaded subscriber monoforum draft = %+v, %v, %v; want suggested post", loadedDraft, found, err)
}
m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001})
suggestedPost := &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}, ScheduleDate: 1700100000}
m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001,
SuggestedPost: suggestedPost,
})
if err != nil {
t.Fatalf("subscriber send 1: %v", err)
}
if m1.Message.SavedPeer != subPeer || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 {
t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message)
}
if len(m1.Recipients) != 2 || !slices.Contains(m1.Recipients, owner.ID) || !slices.Contains(m1.Recipients, sub.ID) {
t.Fatalf("m1 recipients = %v, want subscriber %d + parent admin %d", m1.Recipients, sub.ID, owner.ID)
}
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 112, Message: "again", Date: 1700001002}); err != nil {
t.Fatalf("subscriber send 2: %v", err)
}
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 113, Message: "reply", Date: 1700001003}); err != nil {
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 113, Message: "reply", ReplyTo: &domain.MessageReply{MessageID: m1.Message.ID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1700001003}); err != nil {
t.Fatalf("admin reply: %v", err)
}
@ -85,12 +114,21 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID {
t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID)
}
if _, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil {
t.Fatalf("subscriber main monoforum history = nil err, want denied")
subscriberHist, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
if err != nil {
t.Fatalf("subscriber monoforum history: %v", err)
}
if subscriberHist.Count != 3 || len(subscriberHist.Messages) != 3 {
t.Fatalf("subscriber monoforum history count=%d len=%d, want own 3", subscriberHist.Count, len(subscriberHist.Messages))
}
for _, message := range subscriberHist.Messages {
if message.SavedPeer != subPeer {
t.Fatalf("subscriber history leaked message %+v", message)
}
}
// 幂等。
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001004})
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Date: 1700001004})
if err != nil {
t.Fatalf("dup send: %v", err)
}
@ -124,6 +162,16 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer)
}
}
oldest := hist.Messages[len(hist.Messages)-1]
if oldest.SuggestedPost == nil || oldest.SuggestedPost.Price == nil || oldest.SuggestedPost.Price.Kind != domain.SuggestedPostPriceStars || oldest.SuggestedPost.Price.Amount != 10 || oldest.SuggestedPost.ScheduleDate != 1700100000 {
t.Fatalf("persisted suggested post = %+v, want 10 Stars + schedule", oldest.SuggestedPost)
}
if newest := hist.Messages[0]; newest.ReplyTo == nil || newest.ReplyTo.MessageID != m1.Message.ID {
t.Fatalf("persisted admin reply = %+v, want message %d", newest.ReplyTo, m1.Message.ID)
}
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 114, Message: "bad reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1700001004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) {
t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err)
}
// 另一个订阅者不串会话。
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
@ -134,6 +182,31 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
if subHist.Count != 3 {
t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count)
}
subscriberChannelHistory, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
if err != nil || subscriberChannelHistory.Count != 3 || len(subscriberChannelHistory.Messages) != 3 {
t.Fatalf("subscriber channel history after other = %d/%d, %v; want own 3", subscriberChannelHistory.Count, len(subscriberChannelHistory.Messages), err)
}
for _, message := range subscriberChannelHistory.Messages {
if message.SavedPeer != subPeer {
t.Fatalf("subscriber channel history leaked message %+v", message)
}
}
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: 0, Limit: 100})
if err != nil {
t.Fatalf("subscriber channel difference: %v", err)
}
if len(diff.NewMessages) != 3 {
t.Fatalf("subscriber channel difference messages = %d, want own 3", len(diff.NewMessages))
}
for _, message := range diff.NewMessages {
if message.SavedPeer != subPeer {
t.Fatalf("subscriber difference leaked message %+v", message)
}
}
activeChannelIDs, err := channels.ListActiveChannelIDsForUser(ctx, sub.ID, 0, 10)
if err != nil || !slices.Contains(activeChannelIDs, monoID) {
t.Fatalf("subscriber active channels = %v, %v; want monoforum %d", activeChannelIDs, err, monoID)
}
// 去重按订阅者子会话维度(迁移 0022 唯一索引含 saved_peer_id):管理员用相同 random_id 向两个不同
// 订阅者发,不得互相去重(与 memory 行为一致)。
@ -202,6 +275,13 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit monoforum delete: %v", err)
}
deleteDiff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: deleteEvent.Pts - deleteEvent.PtsCount, Limit: 10})
if err != nil {
t.Fatalf("subscriber difference after own delete: %v", err)
}
if deleteDiff.Pts != deleteEvent.Pts || len(deleteDiff.OtherUpdates) != 1 || len(deleteDiff.OtherUpdates[0].MessageIDs) != 1 || deleteDiff.OtherUpdates[0].MessageIDs[0] != a.Message.ID {
t.Fatalf("subscriber delete difference = %+v, want own deleted id %d at pts %d", deleteDiff, a.Message.ID, deleteEvent.Pts)
}
var ptsBeforeReplay, eventsBeforeReplay int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, monoID).Scan(&ptsBeforeReplay); err != nil {
t.Fatalf("load monoforum pts: %v", err)
@ -227,3 +307,117 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", ptsAfterReplay, eventsAfterReplay, ptsBeforeReplay, eventsBeforeReplay)
}
}
func TestSendPaidMonoforumMessageLedgerPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 191, Phone: "+1789" + suffix + "41", FirstName: "PaidMonoOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
sub, err := users.Create(ctx, domain.User{AccessHash: 192, Phone: "+1789" + suffix + "42", FirstName: "PaidMonoSub"})
if err != nil {
t.Fatalf("create sub: %v", err)
}
other, err := users.Create(ctx, domain.User{AccessHash: 193, Phone: "+1789" + suffix + "43", FirstName: "PaidMonoOther"})
if err != nil {
t.Fatalf("create other: %v", err)
}
channels := NewChannelStore(pool)
broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Paid Mono " + suffix, Broadcast: true, Date: 1700002000})
if err != nil {
t.Fatalf("create channel: %v", err)
}
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, broadcast.Channel.ID, 10, true)
if err != nil {
t.Fatalf("enable paid DM: %v", err)
}
monoID := enabled.Channel.LinkedMonoforumID
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{broadcast.Channel.ID, monoID})
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, sub.ID, other.ID})
})
stars := NewStarsStore(pool)
if _, _, err := stars.EnsureGrant(ctx, sub.ID, 25, 1700002000); err != nil {
t.Fatalf("grant subscriber stars: %v", err)
}
if _, _, err := stars.EnsureGrant(ctx, other.ID, 5, 1700002000); err != nil {
t.Fatalf("grant other stars: %v", err)
}
subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID}
var beforeMessages int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&beforeMessages); err != nil {
t.Fatalf("count messages before paid send: %v", err)
}
lowReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4001, Message: "too low", AllowPaidStars: 9, Date: 1700002001}
var required *domain.StarsPaymentRequiredError
if _, err := channels.SendMonoforumMessage(ctx, lowReq); !errors.As(err, &required) || required.Stars != 10 {
t.Fatalf("low authorization err = %v, want 10-Star payment required", err)
}
var afterLowMessages int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&afterLowMessages); err != nil || afterLowMessages != beforeMessages {
t.Fatalf("low authorization message count = %d/%v, want %d", afterLowMessages, err, beforeMessages)
}
paidReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4002, Message: "paid", AllowPaidStars: 99, Date: 1700002002}
paid, err := channels.SendMonoforumMessage(ctx, paidReq)
if err != nil {
t.Fatalf("paid send: %v", err)
}
if paid.Message.PaidMessageStars != 10 || paid.SenderStarsBalance == nil || paid.SenderStarsBalance.Balance != 15 {
t.Fatalf("paid result = %+v balance=%+v, want actual 10 and balance 15", paid.Message, paid.SenderStarsBalance)
}
var senderBalance, channelBalance, persistedPaid int64
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil {
t.Fatalf("load sender balance: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil {
t.Fatalf("load channel balance: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT paid_message_stars FROM channel_messages WHERE channel_id=$1 AND id=$2`, monoID, paid.Message.ID).Scan(&persistedPaid); err != nil {
t.Fatalf("load persisted paid stars: %v", err)
}
if senderBalance != 15 || channelBalance != 8 || persistedPaid != 10 {
t.Fatalf("persisted sender/channel/message = %d/%d/%d, want 15/8/10", senderBalance, channelBalance, persistedPaid)
}
replay, err := channels.SendMonoforumMessage(ctx, paidReq)
if err != nil {
t.Fatalf("paid replay: %v", err)
}
if !replay.Duplicate || replay.Message.ID != paid.Message.ID || replay.SenderStarsBalance == nil || replay.SenderStarsBalance.Balance != 15 {
t.Fatalf("paid replay = %+v, want exact original and balance 15", replay)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil || senderBalance != 15 {
t.Fatalf("paid replay sender balance = %d/%v, want 15", senderBalance, err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 {
t.Fatalf("paid replay channel balance = %d/%v, want 8", channelBalance, err)
}
admin, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 4003, Message: "free admin reply", AllowPaidStars: 100, Date: 1700002003,
})
if err != nil {
t.Fatalf("admin reply: %v", err)
}
if admin.Message.PaidMessageStars != 0 || admin.SenderStarsBalance != nil {
t.Fatalf("admin reply charged: message=%+v balance=%+v", admin.Message, admin.SenderStarsBalance)
}
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 4004, Message: "insufficient", AllowPaidStars: 10, Date: 1700002004,
}); !errors.Is(err, domain.ErrStarsInsufficient) {
t.Fatalf("insufficient err = %v, want ErrStarsInsufficient", err)
}
var otherBalance int64
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, other.ID).Scan(&otherBalance); err != nil || otherBalance != 5 {
t.Fatalf("insufficient sender balance = %d/%v, want 5", otherBalance, err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 {
t.Fatalf("insufficient channel balance = %d/%v, want 8", channelBalance, err)
}
}

View file

@ -116,7 +116,7 @@ func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore
const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.verified,
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.monoforum, c.linked_monoforum_id, c.slowmode_seconds, c.boosts_unrestrict, c.default_banned_rights::text,
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,
c.available_reactions::text, c.color_set, c.color, c.color_background_emoji_id, c.profile_color_set, c.profile_color, c.profile_color_background_emoji_id, c.emoji_status_document_id, c.emoji_status_until,
c.wallpaper::text, c.participants_count, c.admins_count, c.kicked_count, c.banned_count, c.top_message_id, c.pinned_message_id, c.pts,
c.ttl_period, c.date, c.deleted, c.photo_id, c.photo_dc_id, c.photo_stripped,
@ -126,7 +126,7 @@ const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_p
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body,
entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text,
reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id`
reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id, paid_message_stars, suggested_post::text`
const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id,
title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id,

View file

@ -48,6 +48,10 @@ 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) {
args = append(args, req.UserID)
where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(args))
}
args = append(args, domain.MaxChannelDifferenceTooLongMessages)
rows, err := s.db.Query(ctx, `
SELECT `+channelMessageColumns+`
@ -100,11 +104,15 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
if err != nil {
return domain.ChannelDifference{}, fmt.Errorf("list channel difference: %w", err)
}
defer rows.Close()
diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30}
userRefs := make(map[int64]struct{})
channelRefs := make(map[int64]struct{})
lastPts := req.Pts
type differenceEventRow struct {
event domain.ChannelUpdateEvent
messageID int
}
eventRows := make([]differenceEventRow, 0, limit)
for rows.Next() {
event, messageID, err := scanChannelEvent(rows)
if err != nil {
@ -131,6 +139,27 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
break
}
lastPts = event.Pts
eventRows = append(eventRows, differenceEventRow{event: event, messageID: messageID})
}
if err := rows.Err(); err != nil {
rows.Close()
return domain.ChannelDifference{}, err
}
rows.Close()
var visibleMonoforumMessageIDs map[int]struct{}
if channel.Monoforum && !isChannelAdmin(member) {
messageIDs := make([]int, 0)
for _, row := range eventRows {
messageIDs = append(messageIDs, row.event.MessageIDs...)
}
visibleMonoforumMessageIDs, err = s.monoforumVisibleMessageIDs(ctx, req.ChannelID, req.UserID, messageIDs)
if err != nil {
return domain.ChannelDifference{}, err
}
}
for _, row := range eventRows {
event := row.event
messageID := row.messageID
if messageID != 0 && event.Message.ID == 0 {
msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID)
if err != nil {
@ -143,6 +172,12 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
continue
}
event = visibleEvent
if channel.Monoforum && !isChannelAdmin(member) {
event, ok = filterMonoforumEventForUser(event, req.UserID, visibleMonoforumMessageIDs)
if !ok {
continue
}
}
if preview && event.Type == domain.ChannelUpdateParticipant {
continue
}
@ -156,9 +191,6 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
diff.OtherUpdates = append(diff.OtherUpdates, event)
}
}
if err := rows.Err(); err != nil {
return domain.ChannelDifference{}, err
}
if len(diff.Events) == 0 {
diff.Pts = lastPts
} else if lastPts > diff.Pts {
@ -208,6 +240,55 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
return diff, nil
}
func (s *ChannelStore) monoforumVisibleMessageIDs(ctx context.Context, channelID, userID int64, ids []int) (map[int]struct{}, error) {
visible := make(map[int]struct{})
if len(ids) == 0 {
return visible, nil
}
rows, err := s.db.Query(ctx, `
SELECT id
FROM channel_messages
WHERE channel_id = $1
AND id = ANY($2::int[])
AND saved_peer_type = 'user'
AND saved_peer_id = $3`, channelID, int32s(ids), userID)
if err != nil {
return nil, fmt.Errorf("list visible monoforum message ids: %w", err)
}
defer rows.Close()
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, err
}
visible[id] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, err
}
return visible, nil
}
func filterMonoforumEventForUser(event domain.ChannelUpdateEvent, userID int64, visibleMessageIDs map[int]struct{}) (domain.ChannelUpdateEvent, bool) {
if event.Message.ID != 0 {
return event, event.Message.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID})
}
if len(event.MessageIDs) == 0 {
return event, false
}
ids := make([]int, 0, len(event.MessageIDs))
for _, id := range event.MessageIDs {
if _, ok := visibleMessageIDs[id]; ok {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return event, false
}
event.MessageIDs = ids
return event, true
}
func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int, error) {
var pts int
err := s.db.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&pts)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,138 @@
package postgres
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestCommunityStoreLifecycleIsAtomicInPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 801, Phone: "+1888" + suffix + "01", FirstName: "CommunityOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := users.Create(ctx, domain.User{AccessHash: 802, Phone: "+1888" + suffix + "02", FirstName: "SearchableMember"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channels := NewChannelStore(pool)
initial, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Community Initial " + suffix,
Megagroup: true,
MemberUserIDs: []int64{member.ID},
Date: 1_800_200_000,
})
if err != nil {
t.Fatalf("create initial channel: %v", err)
}
owned, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: member.ID,
Title: "Community Owned " + suffix,
Megagroup: true,
Date: 1_800_200_001,
})
if err != nil {
t.Fatalf("create owned channel: %v", err)
}
owned.Channel, err = channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
UserID: member.ID, ChannelID: owned.Channel.ID, Username: "communitypreview" + suffix,
})
if err != nil {
t.Fatalf("make owned channel public: %v", err)
}
if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: member.ID, ChannelID: owned.Channel.ID, RandomID: 9_900_000_001,
Message: "community public preview search", Date: 1_800_200_001,
}); err != nil {
t.Fatalf("send public preview message: %v", err)
}
var communityID int64
t.Cleanup(func() {
if communityID != 0 {
_, _ = pool.Exec(ctx, "DELETE FROM communities WHERE id=$1", communityID)
}
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id=ANY($1::bigint[])", []int64{initial.Channel.ID, owned.Channel.ID})
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{owner.ID, member.ID})
})
store := NewCommunityStore(pool, nil, nil)
created, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{
CreatorUserID: owner.ID,
Title: "Postgres Community " + suffix,
InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: initial.Channel.ID},
Visibility: domain.CommunityPeerHidden,
Date: 1_800_200_002,
})
if err != nil {
t.Fatalf("create community: %v", err)
}
communityID = created.Community.ID
if len(created.ServiceMessages) != 1 || created.ServiceMessages[0].Event.Pts == 0 {
t.Fatalf("create service messages = %+v", created.ServiceMessages)
}
var linkedID int64
if err := pool.QueryRow(ctx, "SELECT linked_community_id FROM channels WHERE id=$1", initial.Channel.ID).Scan(&linkedID); err != nil || linkedID != communityID {
t.Fatalf("initial linked_community_id = %d err=%v, want %d", linkedID, err, communityID)
}
requested, err := store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{
ActorUserID: member.ID,
CommunityID: communityID,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: owned.Channel.ID},
Visibility: domain.CommunityPeerVisible,
Date: 1_800_200_003,
})
if err != nil || !requested.RequestCreated {
t.Fatalf("create peer link request = %+v err=%v", requested, err)
}
approved, err := store.DecideCommunityPeerLinkRequest(ctx, owner.ID, communityID, requested.Peer, false, 1_800_200_004)
if err != nil || approved.Link == nil || approved.RequestedBy != member.ID || approved.ServiceMessage == nil {
t.Fatalf("approve link request = %+v err=%v", approved, err)
}
search, err := channels.SearchJoinedMessages(ctx, owner.ID, domain.ChannelGlobalSearchRequest{
Query: "public preview", ChannelIDs: []int64{owned.Channel.ID}, RestrictChannelIDs: true,
AllowPublicPreview: true, Limit: 20,
})
if err != nil || len(search.Messages) != 1 || search.Messages[0].ChannelID != owned.Channel.ID {
t.Fatalf("community public-preview search = %+v err=%v", search.Messages, err)
}
participants, err := store.ListCommunityParticipants(ctx, owner.ID, communityID, domain.ChannelParticipantsFilter{
Kind: domain.ChannelParticipantsSearch, Query: "SEARCHABLE",
}, 0, 20)
if err != nil || participants.Count != 1 || len(participants.Participants) != 1 || participants.Participants[0].UserID != member.ID {
t.Fatalf("participant search = %+v err=%v", participants, err)
}
admins, err := store.ListCommunityParticipants(ctx, member.ID, communityID, domain.ChannelParticipantsFilter{
Kind: domain.ChannelParticipantsAdmins,
}, 0, 100)
if err != nil || admins.Count != 1 || len(admins.Participants) != 1 || admins.Participants[0].UserID != owner.ID {
t.Fatalf("member-visible Community admins = %+v err=%v", admins, err)
}
if _, err := store.ListCommunityParticipants(ctx, member.ID, communityID, domain.ChannelParticipantsFilter{
Kind: domain.ChannelParticipantsBanned,
}, 0, 100); !errors.Is(err, domain.ErrCommunityAdminRequired) {
t.Fatalf("member Community banned list error = %v, want admin required", err)
}
ban, err := store.ToggleCommunityParticipantBanned(ctx, owner.ID, communityID, member.ID, false, 1_800_200_005)
if err != nil {
t.Fatalf("ban participant: %v", err)
}
if !ban.Changed || len(ban.ChannelBans) != 1 || len(ban.RemovedLinks) != 1 {
t.Fatalf("ban result = %+v", ban)
}
if err := pool.QueryRow(ctx, "SELECT linked_community_id FROM channels WHERE id=$1", owned.Channel.ID).Scan(&linkedID); err != nil || linkedID != 0 {
t.Fatalf("owned linked_community_id after ban = %d err=%v, want 0", linkedID, err)
}
if _, err := store.GetCommunity(ctx, member.ID, communityID); !errors.Is(err, domain.ErrCommunityPrivate) {
t.Fatalf("banned member get community error = %v, want private", err)
}
}

View file

@ -87,6 +87,8 @@ SELECT
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
@ -137,6 +139,8 @@ SELECT
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
@ -278,6 +282,8 @@ SELECT
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated ru WHERE ru.user_id = c.contact_user_id)::boolean AS reverse_mutual_changed
FROM upserted c
@ -342,6 +348,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
premiumUntil int64
emojiStatusDocID int64
emojiStatusUntil int64
emojiCollectibleID *int64
emojiCollectibleJSON []byte
lastSeenAt int64
reverseMutualChanged bool
)
@ -366,6 +374,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
&premiumUntil,
&emojiStatusDocID,
&emojiStatusUntil,
&emojiCollectibleID,
&emojiCollectibleJSON,
&lastSeenAt,
&reverseMutualChanged,
); err != nil {
@ -376,7 +386,7 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
if err != nil {
return nil, fmt.Errorf("decode contact note entities: %w", err)
}
out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend))
out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate upsert contacts many: %w", err)
@ -556,7 +566,7 @@ func contactFromListRow(row sqlcgen.ListContactsByUserRow) (domain.Contact, erro
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
}
func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) {
@ -564,7 +574,7 @@ func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) {
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
}
func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) {
@ -572,7 +582,7 @@ func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error)
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
}
func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, error) {
@ -580,7 +590,7 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact,
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
}
// contactFromFields 组装 domain.Contact。getContacts 主路径List/Get/Upsert/UpdateNote
@ -588,27 +598,28 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact,
// raw-scan 调用传 false/0——bot 无 phone 不经手机号导入bot 加联系人走 username
// 的单条 UpsertContact 路径(已带真实 bot 列。premium/emoji status 列所有路径必须
// 传真实值TDesktop 对任何缺 emoji_status 字段的 user TL 一律清空本地状态。
func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support, isBot bool, botInfoVersion, premiumUntil int, emojiStatusDocumentID int64, emojiStatusUntil, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual, closeFriend bool) domain.Contact {
func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support, isBot bool, botInfoVersion, premiumUntil int, emojiStatusDocumentID int64, emojiStatusUntil int, emojiCollectibleID *int64, emojiCollectibleJSON []byte, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual, closeFriend bool) domain.Contact {
return domain.Contact{
User: domain.User{
ID: id,
AccessHash: accessHash,
Phone: phone,
FirstName: firstName,
LastName: lastName,
Username: username,
CountryCode: countryCode,
Verified: verified,
Support: support,
Bot: isBot,
BotInfoVersion: botInfoVersion,
PremiumUntil: premiumUntil,
EmojiStatusDocumentID: emojiStatusDocumentID,
EmojiStatusUntil: emojiStatusUntil,
LastSeenAt: lastSeenAt,
Contact: true,
Mutual: mutual,
CloseFriend: closeFriend,
ID: id,
AccessHash: accessHash,
Phone: phone,
FirstName: firstName,
LastName: lastName,
Username: username,
CountryCode: countryCode,
Verified: verified,
Support: support,
Bot: isBot,
BotInfoVersion: botInfoVersion,
PremiumUntil: premiumUntil,
EmojiStatusDocumentID: emojiStatusDocumentID,
EmojiStatusUntil: emojiStatusUntil,
EmojiStatusCollectible: mustDecodeEmojiStatusCollectible(emojiCollectibleID, emojiCollectibleJSON),
LastSeenAt: lastSeenAt,
Contact: true,
Mutual: mutual,
CloseFriend: closeFriend,
},
FirstName: contactFirstName,
LastName: contactLastName,
@ -626,27 +637,29 @@ type contactScanner interface {
func scanContactRows(row contactScanner) (domain.Contact, error) {
var (
contactUserID int64
mutual bool
closeFriend bool
contactPhone string
contactFirstName string
contactLastName string
note string
noteEntitiesJSON string
id int64
accessHash int64
phone string
firstName string
lastName string
username string
countryCode string
verified bool
support bool
premiumUntil int64
emojiStatusDocID int64
emojiStatusUntil int64
lastSeenAt int32
contactUserID int64
mutual bool
closeFriend bool
contactPhone string
contactFirstName string
contactLastName string
note string
noteEntitiesJSON string
id int64
accessHash int64
phone string
firstName string
lastName string
username string
countryCode string
verified bool
support bool
premiumUntil int64
emojiStatusDocID int64
emojiStatusUntil int64
emojiCollectibleID *int64
emojiCollectibleJSON []byte
lastSeenAt int32
)
if err := row.Scan(
&contactUserID,
@ -669,6 +682,8 @@ func scanContactRows(row contactScanner) (domain.Contact, error) {
&premiumUntil,
&emojiStatusDocID,
&emojiStatusUntil,
&emojiCollectibleID,
&emojiCollectibleJSON,
&lastSeenAt,
); err != nil {
return domain.Contact{}, err
@ -677,32 +692,34 @@ func scanContactRows(row contactScanner) (domain.Contact, error) {
if err != nil {
return domain.Contact{}, err
}
return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend), nil
return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend), nil
}
func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
var (
ownerUserID int64
mutual bool
closeFriend bool
contactPhone string
contactFirstName string
contactLastName string
note string
noteEntitiesJSON string
id int64
accessHash int64
phone string
firstName string
lastName string
username string
countryCode string
verified bool
support bool
premiumUntil int64
emojiStatusDocID int64
emojiStatusUntil int64
lastSeenAt int32
ownerUserID int64
mutual bool
closeFriend bool
contactPhone string
contactFirstName string
contactLastName string
note string
noteEntitiesJSON string
id int64
accessHash int64
phone string
firstName string
lastName string
username string
countryCode string
verified bool
support bool
premiumUntil int64
emojiStatusDocID int64
emojiStatusUntil int64
emojiCollectibleID *int64
emojiCollectibleJSON []byte
lastSeenAt int32
)
if err := row.Scan(
&ownerUserID,
@ -725,6 +742,8 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
&premiumUntil,
&emojiStatusDocID,
&emojiStatusUntil,
&emojiCollectibleID,
&emojiCollectibleJSON,
&lastSeenAt,
); err != nil {
return 0, domain.Contact{}, err
@ -733,7 +752,7 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
if err != nil {
return 0, domain.Contact{}, err
}
contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)
contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)
return ownerUserID, contact, nil
}

View file

@ -0,0 +1,54 @@
package postgres
import (
"testing"
"telesrv/internal/domain"
)
func testCollectibleEmojiStatusValue() domain.UserEmojiStatus {
return domain.UserEmojiStatus{
DocumentID: 101,
Until: 2_000_000_000,
Collectible: domain.EmojiStatusCollectible{
CollectibleID: 1001, DocumentID: 101, Title: "Gift", Slug: "Gift-1",
PatternDocumentID: 102, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4,
},
}
}
func TestCollectibleEmojiStatusUserAndEventCodecsRoundTrip(t *testing.T) {
value := testCollectibleEmojiStatusValue()
raw, id, err := encodeEmojiStatusCollectible(value)
if err != nil {
t.Fatalf("encode user collectible: %v", err)
}
if id == nil || *id != value.Collectible.CollectibleID {
t.Fatalf("collectible id = %v", id)
}
if got := mustDecodeEmojiStatusCollectible(id, raw); got != value.Collectible {
t.Fatalf("decoded user collectible = %+v, want %+v", got, value.Collectible)
}
eventRaw, err := encodeEventEmojiStatus(value)
if err != nil {
t.Fatalf("encode event collectible: %v", err)
}
got, err := decodeEventEmojiStatus(string(eventRaw))
if err != nil || got != value {
t.Fatalf("decoded event collectible = %+v err=%v, want %+v", got, err, value)
}
}
func TestCollectibleEmojiStatusCodecRejectsPartialSnapshot(t *testing.T) {
value := domain.UserEmojiStatus{
DocumentID: 101,
Collectible: domain.EmojiStatusCollectible{CollectibleID: 1001, DocumentID: 101},
}
if _, _, err := encodeEmojiStatusCollectible(value); err == nil {
t.Fatal("partial user snapshot encoded successfully")
}
if _, err := encodeEventEmojiStatus(value); err == nil {
t.Fatal("partial event snapshot encoded successfully")
}
}

View file

@ -0,0 +1,62 @@
package postgres
import (
"context"
"fmt"
"testing"
"time"
"telesrv/internal/domain"
)
func TestUpdateEmojiStatusWithEventIsAtomic(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
u, err := users.Create(ctx, domain.User{
AccessHash: time.Now().UnixNano(),
Phone: fmt.Sprintf("1666%d", time.Now().UnixNano()),
FirstName: "Emoji status event",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, u.ID) })
status := domain.UserEmojiStatus{DocumentID: 42}
event := domain.UpdateEvent{
Type: domain.UpdateEventUserEmojiStatus,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: u.ID},
EmojiStatus: status,
Date: int(time.Now().Unix()),
PtsCount: 1,
}
// A nonzero session without its auth-key half violates the outbox
// exclusion-pair invariant. The event failure must roll back the users row.
if _, _, err := users.UpdateEmojiStatusWithEvent(ctx, u.ID, status, event, [8]byte{}, 77); err == nil {
t.Fatal("UpdateEmojiStatusWithEvent unexpectedly accepted a partial exclusion pair")
}
got, found, err := users.ByID(ctx, u.ID)
if err != nil || !found || !got.EmojiStatus().Empty() {
t.Fatalf("failed aggregate write leaked user state: user=%+v found=%v err=%v", got, found, err)
}
authKeyID := [8]byte{1, 2, 3, 4, 5, 6, 7, 8}
got, storedEvent, err := users.UpdateEmojiStatusWithEvent(ctx, u.ID, status, event, authKeyID, 77)
if err != nil {
t.Fatalf("UpdateEmojiStatusWithEvent: %v", err)
}
if got.EmojiStatus() != status || storedEvent.Pts <= 0 || storedEvent.EmojiStatus != status {
t.Fatalf("aggregate result: user=%+v event=%+v", got.EmojiStatus(), storedEvent)
}
loaded, err := NewUpdateEventStore(pool).ListAfter(ctx, u.ID, storedEvent.Pts-1, 1)
if err != nil || len(loaded) != 1 || loaded[0].EmojiStatus != status {
t.Fatalf("durable event: events=%+v err=%v", loaded, err)
}
var outboxCount int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*) FROM dispatch_outbox
WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, u.ID, storedEvent.Pts).Scan(&outboxCount); err != nil || outboxCount != 1 {
t.Fatalf("dispatch outbox count=%d err=%v", outboxCount, err)
}
}

View file

@ -0,0 +1,49 @@
package postgres
import (
"context"
"encoding/json"
"fmt"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// EphemeralReportStore persists the low-volume abuse-review evidence path.
// The hot ephemeral send/edit/delete path remains entirely in Redis.
type EphemeralReportStore struct {
db sqlcgen.DBTX
}
func NewEphemeralReportStore(db sqlcgen.DBTX) *EphemeralReportStore {
return &EphemeralReportStore{db: db}
}
func (s *EphemeralReportStore) CreateEphemeralReport(ctx context.Context, report domain.EphemeralAbuseReport) (bool, error) {
if s == nil || s.db == nil {
return false, fmt.Errorf("ephemeral report store is not configured")
}
if err := report.Validate(); err != nil {
return false, err
}
evidence, err := json.Marshal(report.Evidence)
if err != nil {
return false, fmt.Errorf("marshal ephemeral report evidence: %w", err)
}
tag, err := s.db.Exec(ctx, `
INSERT INTO ephemeral_abuse_reports (
reporter_user_id, channel_id, ephemeral_message_id, sender_user_id,
receiver_user_id, report_option, report_comment, comment_hash,
payload_hash, evidence, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11)
ON CONFLICT (
reporter_user_id, channel_id, ephemeral_message_id, report_option, comment_hash
) DO NOTHING
`, report.ReporterUserID, report.Evidence.Peer.ID, report.Evidence.MessageID,
report.Evidence.SenderUserID, report.Evidence.ReceiverUserID,
report.Option, report.Comment, report.CommentHash[:], report.Evidence.PayloadHash[:], evidence, report.CreatedAt)
if err != nil {
return false, fmt.Errorf("insert ephemeral abuse report: %w", err)
}
return tag.RowsAffected() == 1, nil
}

View file

@ -0,0 +1,60 @@
package postgres
import (
"context"
"encoding/json"
"testing"
"time"
"telesrv/internal/domain"
)
func TestEphemeralReportStoreDurableEvidenceAndIdempotency(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now()
reporter := now.UnixNano()&0x3fffffff + 1000
sender := reporter + 1
messageID := int(now.UnixNano()&0x3fffffff) + 1
message := domain.EphemeralMessage{
ID: messageID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: reporter + 2},
SenderUserID: sender, ReceiverUserID: reporter, Date: int(now.Unix()), RandomID: 99,
Content: domain.EphemeralContent{Message: "abuse evidence"},
OriginDevice: domain.EphemeralDevice{UserID: reporter, BusinessAuthKeyID: [8]byte{1, 2, 3}, SessionID: 44},
Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
report := domain.NewEphemeralAbuseReport(reporter, "spam", "review this", message, now)
store := NewEphemeralReportStore(pool)
created, err := store.CreateEphemeralReport(ctx, report)
if err != nil || !created {
t.Fatalf("create=%v err=%v", created, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM ephemeral_abuse_reports WHERE reporter_user_id = $1", reporter)
})
if created, err := store.CreateEphemeralReport(ctx, report); err != nil || created {
t.Fatalf("retry create=%v err=%v", created, err)
}
var evidenceRaw []byte
var count int
if err := pool.QueryRow(ctx, `
SELECT evidence, count(*) OVER ()
FROM ephemeral_abuse_reports
WHERE reporter_user_id = $1 AND channel_id = $2 AND ephemeral_message_id = $3
`, reporter, message.Peer.ID, message.ID).Scan(&evidenceRaw, &count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("rows=%d", count)
}
var evidence map[string]any
if err := json.Unmarshal(evidenceRaw, &evidence); err != nil {
t.Fatal(err)
}
if evidence["MessageID"] != float64(message.ID) || evidence["Content"] == nil {
t.Fatalf("evidence=%s", evidenceRaw)
}
if _, leaked := evidence["OriginDevice"]; leaked {
t.Fatalf("device identity leaked into report evidence: %s", evidenceRaw)
}
}

View file

@ -46,7 +46,7 @@ func (s *MessageStore) DefaultHistoryTTL(ctx context.Context, userID int64) (int
return 0, nil
}
var period int
err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1`, userID).Scan(&period)
err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1 AND deleted_at IS NULL`, userID).Scan(&period)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
@ -70,7 +70,7 @@ func (s *MessageStore) SetDefaultHistoryTTL(ctx context.Context, userID int64, p
UPDATE users
SET default_history_ttl_period = $2,
updated_at = now()
WHERE id = $1
WHERE id = $1 AND deleted_at IS NULL
`, userID, period)
if err != nil {
return fmt.Errorf("set default history ttl: %w", err)

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
@ -208,7 +209,7 @@ func TestLoginCodeDeliveryPostgresCommitAckLossRecoversFromReceipt(t *testing.T)
}
}
func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialUser(t *testing.T) {
func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialIdentity(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
firstUser := createLoginCodeDeliveryTestUser(t, ctx, pool, "official-row-first")
@ -222,6 +223,16 @@ func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialUser(t *test
if err := pool.QueryRow(ctx, `SELECT xmin::text FROM users WHERE id = $1`, domain.OfficialSystemUserID).Scan(&xminBefore); err != nil {
t.Fatalf("load official user xmin: %v", err)
}
var usernameBefore, usernameXminBefore string
if err := pool.QueryRow(ctx, `
SELECT username_lower, xmin::text
FROM peer_usernames
WHERE peer_type = 'user' AND peer_id = $1`, domain.OfficialSystemUserID).Scan(&usernameBefore, &usernameXminBefore); err != nil {
t.Fatalf("load official username identity: %v", err)
}
if want := strings.ToLower(domain.OfficialSystemUser().Username); usernameBefore != want {
t.Fatalf("official username = %q, want %q", usernameBefore, want)
}
const workers = 12
users := make([]domain.User, workers)
@ -256,6 +267,16 @@ func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialUser(t *test
if xminAfter != xminBefore {
t.Fatalf("official system user row was rewritten: xmin %s -> %s", xminBefore, xminAfter)
}
var usernameAfter, usernameXminAfter string
if err := pool.QueryRow(ctx, `
SELECT username_lower, xmin::text
FROM peer_usernames
WHERE peer_type = 'user' AND peer_id = $1`, domain.OfficialSystemUserID).Scan(&usernameAfter, &usernameXminAfter); err != nil {
t.Fatalf("reload official username identity: %v", err)
}
if usernameAfter != usernameBefore || usernameXminAfter != usernameXminBefore {
t.Fatalf("official username identity was rewritten: %q/%s -> %q/%s", usernameBefore, usernameXminBefore, usernameAfter, usernameXminAfter)
}
}
func TestLoginCodeDeliveryPostgresReceiptRetentionIsBoundedAndSeekOrdered(t *testing.T) {

View file

@ -550,6 +550,56 @@ func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool
return photo, true, nil
}
// GetPhotos resolves a bounded set of immutable photo metadata with one indexed
// ANY query. Missing ids are omitted and the result follows first-seen caller order.
func (s *MediaStore) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) {
if len(ids) == 0 {
return nil, nil
}
unique := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
if len(unique) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, `
SELECT id, access_hash, file_reference, date, dc_id, has_stickers, sizes::text
FROM photos
WHERE id = ANY($1::bigint[])
`, unique)
if err != nil {
return nil, err
}
defer rows.Close()
byID := make(map[int64]domain.Photo, len(unique))
for rows.Next() {
photo, err := scanPhotoRow(rows)
if err != nil {
return nil, err
}
byID[photo.ID] = photo
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]domain.Photo, 0, len(byID))
for _, id := range unique {
if photo, ok := byID[id]; ok {
out = append(out, photo)
}
}
return out, nil
}
type photoScanner interface {
Scan(dest ...any) error
}

View file

@ -2,6 +2,7 @@ package postgres
import (
"encoding/json"
"fmt"
"reflect"
"telesrv/internal/domain"
@ -44,9 +45,12 @@ func decodeMessageMedia(s string) (*domain.MessageMedia, error) {
return &m, nil
}
// encodeReplyMarkup 把 inline keyboard 快照序列化为 JSONB空 markup 序列化为 "{}"。
// encodeReplyMarkup 把 reply/inline keyboard 快照序列化为 JSONB空 markup 序列化为 "{}"。
// callback data 是 []bytejson.Marshal 自动 base64保证经 JSONB 字节级 round-trip
func encodeReplyMarkup(m *domain.MessageReplyMarkup) ([]byte, error) {
if err := domain.ValidateReplyMarkup(m); err != nil {
return nil, fmt.Errorf("encode reply markup: %w", err)
}
if m.IsZero() {
return []byte("{}"), nil
}
@ -63,6 +67,9 @@ func decodeReplyMarkup(s string) (*domain.MessageReplyMarkup, error) {
if err := json.Unmarshal([]byte(s), &m); err != nil {
return nil, err
}
if err := domain.ValidateReplyMarkup(&m); err != nil {
return nil, fmt.Errorf("decode reply markup: %w", err)
}
if m.IsZero() {
return nil, nil
}

View file

@ -91,6 +91,10 @@ func TestMediaStoreRoundTrip(t *testing.T) {
if err != nil || !ok || len(gotPhoto.Sizes) != 1 || gotPhoto.Sizes[0].Type != "x" {
t.Fatalf("get photo mismatch: ok=%v err=%v photo=%+v", ok, err, gotPhoto)
}
photos, err := s.GetPhotos(ctx, []int64{photoID, 0, photoID, photoID + 99})
if err != nil || len(photos) != 1 || photos[0].ID != photoID || len(photos[0].Sizes) != 1 {
t.Fatalf("get photos mismatch: photos=%+v err=%v", photos, err)
}
// ---- sticker set ----
set := domain.StickerSet{

View file

@ -331,19 +331,20 @@ func appendDeleteMessagesEvent(ctx context.Context, q *sqlcgen.Queries, event do
event.PtsCount = 1
}
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
UserID: event.UserID,
Pts: int32(event.Pts),
PtsCount: int32(event.PtsCount),
Date: int32(event.Date),
EventType: string(domain.UpdateEventDeleteMessages),
EventPeers: []byte("[]"),
PeerSettings: []byte("{}"),
MessageIds: messageIDs,
DialogFilter: []byte("{}"),
FilterOrder: []byte("[]"),
FolderPeers: []byte("[]"),
StoryPayload: []byte("{}"),
ReactionPayload: []byte("{}"),
UserID: event.UserID,
Pts: int32(event.Pts),
PtsCount: int32(event.PtsCount),
Date: int32(event.Date),
EventType: string(domain.UpdateEventDeleteMessages),
EventPeers: []byte("[]"),
PeerSettings: []byte("{}"),
MessageIds: messageIDs,
DialogFilter: []byte("{}"),
FilterOrder: []byte("[]"),
FolderPeers: []byte("[]"),
StoryPayload: []byte("{}"),
ReactionPayload: []byte("{}"),
EmojiStatusPayload: []byte("{}"),
}); err != nil {
return fmt.Errorf("append delete messages event: %w", err)
}

View file

@ -51,6 +51,27 @@ func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (d
return out, nil
}
// GetByUID resolves one owner's box row by the indexed shared private_message_id.
func (s *MessageStore) GetByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) {
if userID == 0 || uid == 0 {
return domain.Message{}, false, nil
}
row, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
OwnerUserID: userID,
PrivateMessageID: uid,
})
if errors.Is(err, pgx.ErrNoRows) {
return domain.Message{}, false, nil
}
if err != nil {
return domain.Message{}, false, fmt.Errorf("get message by uid: %w", err)
}
if _, err := decodeReplyMarkup(row.ReplyMarkupJson); err != nil {
return domain.Message{}, false, fmt.Errorf("get message by uid reply markup: %w", err)
}
return messageFromGetBoxRow(row), true, nil
}
func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
limit := filter.Limit
if limit <= 0 {
@ -79,21 +100,23 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
var rows []sqlcgen.ListMessagesByUserRow
if addOffset >= 0 {
bw, err := s.q.ListMessagesBackward(ctx, sqlcgen.ListMessagesBackwardParams{
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
Query: filter.Query,
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
OffsetID: pgInt32NonNegative(filter.OffsetID),
RowOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
OffsetID: pgInt32NonNegative(filter.OffsetID),
RowOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
})
if err != nil {
return domain.MessageList{}, fmt.Errorf("list messages (backward): %w", err)
@ -104,17 +127,19 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
}
if filter.NeedTotalCount {
total, err := s.q.CountMessagesByUser(ctx, sqlcgen.CountMessagesByUserParams{
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
Query: filter.Query,
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
})
if err != nil {
return domain.MessageList{}, fmt.Errorf("count messages: %w", err)
@ -128,22 +153,24 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
} else {
var err error
rows, err = s.q.ListMessagesByUser(ctx, sqlcgen.ListMessagesByUserParams{
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
Query: filter.Query,
OffsetID: pgInt32NonNegative(filter.OffsetID),
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
AddOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
NeedTotalCount: filter.NeedTotalCount,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
OffsetID: pgInt32NonNegative(filter.OffsetID),
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
AddOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
NeedTotalCount: filter.NeedTotalCount,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
})
if err != nil {
return domain.MessageList{}, fmt.Errorf("list messages: %w", err)

View file

@ -0,0 +1,42 @@
package postgres
import (
"testing"
"telesrv/internal/domain"
)
func TestReplyMarkupCodecValidatesTaggedUnion(t *testing.T) {
keyboard := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}},
Resize: true,
Placeholder: "Choose",
}
raw, err := encodeReplyMarkup(keyboard)
if err != nil {
t.Fatalf("encode reply keyboard: %v", err)
}
got, err := decodeReplyMarkup(string(raw))
if err != nil || got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard ||
len(got.Keyboard) != 1 || got.Keyboard[0][0].Text != "Help" || !got.Resize || got.Placeholder != "Choose" {
t.Fatalf("decoded reply keyboard = %#v, err=%v", got, err)
}
// Pre-union inline snapshots intentionally remain readable.
legacy, err := decodeReplyMarkup(`{"inline":[[{"type":"callback","text":"OK","data":"b2s="}]]}`)
if err != nil || legacy == nil || legacy.Kind() != domain.MessageReplyMarkupInline {
t.Fatalf("legacy inline markup = %#v, err=%v", legacy, err)
}
malformed := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupInline,
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "wrong"}}},
}
if _, err := encodeReplyMarkup(malformed); err == nil {
t.Fatal("malformed union must fail at the write boundary")
}
if _, err := decodeReplyMarkup(`{"type":"inline","keyboard":[[{"type":"text","text":"wrong"}]]}`); err == nil {
t.Fatal("malformed stored union must fail at the read boundary")
}
}

View file

@ -66,9 +66,46 @@ func ensureOfficialSystemUserWithDB(ctx context.Context, db sqlcgen.DBTX, msg do
return nil
}
if _, err := db.Exec(ctx, `
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (id) DO NOTHING
WITH desired (
id, access_hash, phone, first_name, last_name, username,
country_code, verified, support, about, is_bot, bot_info_version
) AS (
VALUES ($1::bigint, $2::bigint, $3::text, $4::text, $5::text, $6::text,
$7::text, $8::boolean, $9::boolean, $10::text, $11::boolean, $12::integer)
), upserted AS (
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version)
SELECT id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version
FROM desired
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
phone = EXCLUDED.phone,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
username = EXCLUDED.username,
country_code = EXCLUDED.country_code,
verified = EXCLUDED.verified,
support = EXCLUDED.support,
about = EXCLUDED.about,
is_bot = EXCLUDED.is_bot,
bot_info_version = EXCLUDED.bot_info_version,
updated_at = now()
WHERE (
users.access_hash, users.phone, users.first_name, users.last_name,
users.username, users.country_code, users.verified, users.support,
users.about, users.is_bot, users.bot_info_version
) IS DISTINCT FROM (
EXCLUDED.access_hash, EXCLUDED.phone, EXCLUDED.first_name, EXCLUDED.last_name,
EXCLUDED.username, EXCLUDED.country_code, EXCLUDED.verified, EXCLUDED.support,
EXCLUDED.about, EXCLUDED.is_bot, EXCLUDED.bot_info_version
)
)
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
SELECT lower(username), 'user', id
FROM desired
ON CONFLICT (peer_type, peer_id) DO UPDATE SET
username_lower = EXCLUDED.username_lower,
updated_at = now()
WHERE peer_usernames.username_lower IS DISTINCT FROM EXCLUDED.username_lower
`, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About, u.Bot, u.BotInfoVersion); err != nil {
return fmt.Errorf("ensure official system user: %w", err)
}
@ -117,7 +154,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
if err != nil {
return domain.SendPrivateTextResult{}, err
}
// reply_markupbot inline keyboard随消息一并入双盒普通用户发送恒 nil → "{}"。
// reply_markupbot reply/inline keyboard随消息一并入双盒普通用户发送恒 nil → "{}"。
replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup)
if err != nil {
return domain.SendPrivateTextResult{}, err
@ -656,22 +693,23 @@ func appendNewMessageEvent(ctx context.Context, q *sqlcgen.Queries, msg domain.M
peerType := string(msg.Peer.Type)
peerID := msg.Peer.ID
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
UserID: msg.OwnerUserID,
Pts: int32(msg.Pts),
PtsCount: 1,
Date: int32(msg.Date),
EventType: string(domain.UpdateEventNewMessage),
EventPeers: []byte("[]"),
PeerSettings: []byte("{}"),
MessageIds: []byte("[]"),
DialogFilter: []byte("{}"),
FilterOrder: []byte("[]"),
FolderPeers: []byte("[]"),
StoryPayload: []byte("{}"),
ReactionPayload: []byte("{}"),
MessageBoxID: &boxID,
PeerType: &peerType,
PeerID: &peerID,
UserID: msg.OwnerUserID,
Pts: int32(msg.Pts),
PtsCount: 1,
Date: int32(msg.Date),
EventType: string(domain.UpdateEventNewMessage),
EventPeers: []byte("[]"),
PeerSettings: []byte("{}"),
MessageIds: []byte("[]"),
DialogFilter: []byte("{}"),
FilterOrder: []byte("[]"),
FolderPeers: []byte("[]"),
StoryPayload: []byte("{}"),
ReactionPayload: []byte("{}"),
EmojiStatusPayload: []byte("{}"),
MessageBoxID: &boxID,
PeerType: &peerType,
PeerID: &peerID,
}); err != nil {
return fmt.Errorf("append new message event: %w", err)
}

View file

@ -205,6 +205,70 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
assertWebViewData("recipient event", events[0].Message)
}
func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1666"+suffix+"33", "RequestedSender", "")
recipient := createTestUser(t, ctx, users, "+1666"+suffix+"34", "RequestedRecipient", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
requestedPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 5501}
photo := domain.Photo{ID: 8201, Sizes: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096,
}}}
messages := NewMessageStore(pool)
got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: 9002, Date: 1700000212,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer,
RequestedPeer: &domain.MessageRequestedPeerAction{
ButtonID: 88, Peers: []domain.Peer{requestedPeer},
Details: []domain.MessageRequestedPeerDetails{{
Peer: requestedPeer, Title: "Shared Chat", Username: "shared_chat", Photo: &photo,
}},
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
},
}},
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
assertSnapshot := func(name string, msg domain.Message) {
t.Helper()
if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil {
t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media)
}
action := msg.Media.ServiceAction.RequestedPeer
if action.ButtonID != 88 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer ||
len(action.Details) != 1 || action.Details[0].Title != "Shared Chat" ||
action.Details[0].Username != "shared_chat" || action.Details[0].Photo == nil ||
len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 ||
!action.NameRequested || !action.UsernameRequested || !action.PhotoRequested {
t.Fatalf("%s requested-peer=%+v", name, action)
}
}
assertSnapshot("sender", got.SenderMessage)
assertSnapshot("recipient", got.RecipientMessage)
history, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10,
})
if err != nil || len(history.Messages) != 1 {
t.Fatalf("recipient history=%+v err=%v", history, err)
}
assertSnapshot("recipient history", history.Messages[0])
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10)
if err != nil || len(events) != 1 {
t.Fatalf("recipient events=%+v err=%v", events, err)
}
assertSnapshot("recipient event", events[0].Message)
}
func TestMessageStorePhoneCallServiceFirstMessageFeedsDialogsAndUpdates(t *testing.T) {
pool := testPool(t)
ctx := context.Background()

View file

@ -22,6 +22,8 @@ SELECT
u.premium_expires_at,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
@ -52,6 +54,8 @@ SELECT
u.premium_expires_at,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
@ -130,6 +134,8 @@ SELECT
u.premium_expires_at,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
FROM upserted c
@ -168,6 +174,8 @@ SELECT
u.premium_expires_at,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at
FROM updated c
JOIN users u ON u.id = c.contact_user_id;

View file

@ -537,6 +537,10 @@ base AS NOT MATERIALIZED (
NOT sqlc.arg(has_peer)::boolean
OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint)
)
AND (
NOT sqlc.arg(restrict_peer_ids)::boolean
OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[]))
)
AND (
sqlc.arg(query)::text = ''
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
@ -798,6 +802,10 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
NOT sqlc.arg(has_peer)::boolean
OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint)
)
AND (
NOT sqlc.arg(restrict_peer_ids)::boolean
OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[]))
)
AND (
sqlc.arg(query)::text = ''
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
@ -840,6 +848,10 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
NOT sqlc.arg(has_peer)::boolean
OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint)
)
AND (
NOT sqlc.arg(restrict_peer_ids)::boolean
OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[]))
)
AND (
sqlc.arg(query)::text = ''
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')

View file

@ -8,7 +8,7 @@ WHERE id = ANY(sqlc.arg(ids)::bigint[])
ORDER BY id;
-- name: GetUserByPhone :one
SELECT * FROM users WHERE phone = $1;
SELECT * FROM users WHERE phone = $1 AND deleted_at IS NULL;
-- name: GetUserBySignupEmail :one
SELECT * FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> '';
@ -16,11 +16,11 @@ SELECT * FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> ''
-- name: GetUsersByPhones :many
SELECT *
FROM users
WHERE phone = ANY(sqlc.arg(phones)::text[])
WHERE phone = ANY(sqlc.arg(phones)::text[]) AND deleted_at IS NULL
ORDER BY id;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '';
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL;
-- name: SearchUsers :many
WITH matched AS (
@ -40,12 +40,15 @@ WITH matched AS (
u.premium_expires_at,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.color_set,
u.color,
u.color_background_emoji_id,
u.profile_color_set,
u.profile_color,
u.profile_color_background_emoji_id,
u.linked_community_id,
u.last_seen_at,
(c.contact_user_id IS NOT NULL)::boolean AS contact,
COALESCE(c.mutual, false)::boolean AS mutual,
@ -60,6 +63,7 @@ WITH matched AS (
FROM users u
LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id
WHERE u.id <> sqlc.arg(current_user_id)::bigint
AND u.deleted_at IS NULL
AND sqlc.arg(query_lower)::text <> ''
AND (
(sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%')
@ -88,12 +92,15 @@ SELECT
premium_expires_at,
emoji_status_document_id,
emoji_status_until,
emoji_status_collectible_id,
emoji_status_collectible,
color_set,
color,
color_background_emoji_id,
profile_color_set,
profile_color,
profile_color_background_emoji_id,
linked_community_id,
last_seen_at,
contact,
mutual
@ -110,14 +117,14 @@ RETURNING *;
UPDATE users
SET username = $2,
updated_at = now()
WHERE id = $1
WHERE id = $1 AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserLastSeen :exec
UPDATE users
SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint),
updated_at = now()
WHERE id = sqlc.arg(id)::bigint;
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL;
-- name: UpdateUserProfile :one
UPDATE users
@ -125,14 +132,14 @@ SET first_name = $2,
last_name = $3,
about = $4,
updated_at = now()
WHERE id = $1
WHERE id = $1 AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserPhone :one
UPDATE users
SET phone = sqlc.arg(phone)::text,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserPhoneAndSignupEmail :one
@ -147,14 +154,14 @@ RETURNING *;
UPDATE users
SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: SetUserVerified :one
UPDATE users
SET verified = sqlc.arg(verified)::boolean,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: SweepExpiredPremium :many
@ -164,6 +171,7 @@ SET premium_expires_at = NULL,
WHERE id IN (
SELECT id FROM users
WHERE premium_expires_at IS NOT NULL
AND deleted_at IS NULL
AND premium_expires_at <= sqlc.arg(now)::timestamptz
ORDER BY premium_expires_at
LIMIT sqlc.arg(limit_count)::int
@ -174,8 +182,10 @@ RETURNING *;
UPDATE users
SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint,
emoji_status_until = sqlc.arg(emoji_status_until)::bigint,
emoji_status_collectible_id = sqlc.narg(emoji_status_collectible_id)::bigint,
emoji_status_collectible = sqlc.arg(emoji_status_collectible)::jsonb,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserBirthday :one
@ -184,14 +194,14 @@ SET birthday_day = sqlc.arg(birthday_day)::int,
birthday_month = sqlc.arg(birthday_month)::int,
birthday_year = sqlc.arg(birthday_year)::int,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserPersonalChannel :one
UPDATE users
SET personal_channel_id = sqlc.arg(personal_channel_id)::bigint,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserColor :one
@ -200,7 +210,7 @@ SET color_set = sqlc.arg(color_set)::boolean,
color = sqlc.arg(color)::int,
color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserProfileColor :one
@ -209,5 +219,5 @@ SET profile_color_set = sqlc.arg(color_set)::boolean,
profile_color = sqlc.arg(color)::int,
profile_color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;

View file

@ -15,6 +15,7 @@ INSERT INTO user_update_events (
folder_peers,
story_payload,
reaction_payload,
emoji_status_payload,
message_box_id,
peer_type,
peer_id,
@ -40,6 +41,7 @@ INSERT INTO user_update_events (
sqlc.arg(folder_peers)::jsonb,
sqlc.arg(story_payload)::jsonb,
sqlc.arg(reaction_payload)::jsonb,
sqlc.arg(emoji_status_payload)::jsonb,
sqlc.narg(message_box_id),
sqlc.narg(peer_type)::text,
sqlc.narg(peer_id)::bigint,
@ -68,6 +70,7 @@ SELECT
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
COALESCE(e.peer_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id,
@ -341,6 +344,7 @@ SELECT
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
COALESCE(e.peer_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id,

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, 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, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
`
type InsertBotUserParams struct {
@ -209,6 +209,13 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err

View file

@ -67,6 +67,8 @@ SELECT
u.premium_expires_at,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
@ -80,29 +82,31 @@ type GetContactParams struct {
}
type GetContactRow struct {
ContactUserID int64
Mutual bool
CloseFriend bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
IsBot bool
BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64
EmojiStatusUntil int64
LastSeenAt int64
ContactUserID int64
Mutual bool
CloseFriend bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
IsBot bool
BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64
EmojiStatusUntil int64
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
LastSeenAt int64
}
func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) {
@ -131,6 +135,8 @@ func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetCont
&i.PremiumExpiresAt,
&i.EmojiStatusDocumentID,
&i.EmojiStatusUntil,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LastSeenAt,
)
return i, err
@ -160,6 +166,8 @@ SELECT
u.premium_expires_at,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
@ -168,29 +176,31 @@ ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u
`
type ListContactsByUserRow struct {
ContactUserID int64
Mutual bool
CloseFriend bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
IsBot bool
BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64
EmojiStatusUntil int64
LastSeenAt int64
ContactUserID int64
Mutual bool
CloseFriend bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
IsBot bool
BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64
EmojiStatusUntil int64
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
LastSeenAt int64
}
func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) {
@ -225,6 +235,8 @@ func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListC
&i.PremiumExpiresAt,
&i.EmojiStatusDocumentID,
&i.EmojiStatusUntil,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LastSeenAt,
); err != nil {
return nil, err
@ -270,6 +282,8 @@ SELECT
u.premium_expires_at,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at
FROM updated c
JOIN users u ON u.id = c.contact_user_id
@ -283,29 +297,31 @@ type UpdateContactNoteParams struct {
}
type UpdateContactNoteRow struct {
ContactUserID int64
Mutual bool
CloseFriend bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
IsBot bool
BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64
EmojiStatusUntil int64
LastSeenAt int64
ContactUserID int64
Mutual bool
CloseFriend bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
IsBot bool
BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64
EmojiStatusUntil int64
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
LastSeenAt int64
}
func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) {
@ -339,6 +355,8 @@ func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNotePa
&i.PremiumExpiresAt,
&i.EmojiStatusDocumentID,
&i.EmojiStatusUntil,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LastSeenAt,
)
return i, err
@ -416,6 +434,8 @@ SELECT
u.premium_expires_at,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
FROM upserted c
@ -433,30 +453,32 @@ type UpsertContactParams struct {
}
type UpsertContactRow struct {
ContactUserID int64
Mutual bool
CloseFriend bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
IsBot bool
BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64
EmojiStatusUntil int64
LastSeenAt int64
ReverseMutualChanged bool
ContactUserID int64
Mutual bool
CloseFriend bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
IsBot bool
BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64
EmojiStatusUntil int64
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
LastSeenAt int64
ReverseMutualChanged bool
}
func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) {
@ -493,6 +515,8 @@ func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (U
&i.PremiumExpiresAt,
&i.EmojiStatusDocumentID,
&i.EmojiStatusUntil,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LastSeenAt,
&i.ReverseMutualChanged,
)

View file

@ -19,14 +19,18 @@ WHERE m.owner_user_id = $1::bigint
OR (m.peer_type = $3::text AND m.peer_id = $4::bigint)
)
AND (
$5::text = ''
OR m.body ILIKE ('%' || $5::text || '%')
NOT $5::boolean
OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[]))
)
AND ($6::int <= 0 OR m.box_id < $6::int)
AND ($7::int <= 0 OR m.box_id > $7::int)
AND (NOT $8::boolean OR m.pinned)
AND (
NOT $9::boolean
$7::text = ''
OR m.body ILIKE ('%' || $7::text || '%')
)
AND ($8::int <= 0 OR m.box_id < $8::int)
AND ($9::int <= 0 OR m.box_id > $9::int)
AND (NOT $10::boolean OR m.pinned)
AND (
NOT $11::boolean
OR (
m.media->>'kind' = 'document'
AND EXISTS (
@ -38,23 +42,25 @@ WHERE m.owner_user_id = $1::bigint
)
)
AND (
$10::text = ''
OR (m.saved_peer_type = $10::text AND m.saved_peer_id = $11::bigint)
$12::text = ''
OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint)
)
`
type CountMessagesByUserParams struct {
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
}
// ListMessagesByUser total CTE 的独立化:相同 base 过滤(不含分页 anchor),
@ -65,6 +71,8 @@ func (q *Queries) CountMessagesByUser(ctx context.Context, arg CountMessagesByUs
arg.HasPeer,
arg.PeerType,
arg.PeerID,
arg.RestrictPeerIds,
arg.PeerIds,
arg.Query,
arg.MaxID,
arg.MinID,
@ -2280,14 +2288,18 @@ WHERE m.owner_user_id = $1::bigint
OR (m.peer_type = $3::text AND m.peer_id = $4::bigint)
)
AND (
$5::text = ''
OR m.body ILIKE ('%' || $5::text || '%')
NOT $5::boolean
OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[]))
)
AND ($6::int <= 0 OR m.box_id < $6::int)
AND ($7::int <= 0 OR m.box_id > $7::int)
AND (NOT $8::boolean OR m.pinned)
AND (
NOT $9::boolean
$7::text = ''
OR m.body ILIKE ('%' || $7::text || '%')
)
AND ($8::int <= 0 OR m.box_id < $8::int)
AND ($9::int <= 0 OR m.box_id > $9::int)
AND (NOT $10::boolean OR m.pinned)
AND (
NOT $11::boolean
OR (
m.media->>'kind' = 'document'
AND EXISTS (
@ -2299,34 +2311,36 @@ WHERE m.owner_user_id = $1::bigint
)
)
AND (
$10::text = ''
OR (m.saved_peer_type = $10::text AND m.saved_peer_id = $11::bigint)
$12::text = ''
OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint)
)
AND (
($12::int > 0 AND m.message_date < $12::int)
OR ($12::int <= 0 AND ($13::int <= 0 OR m.box_id < $13::int))
($14::int > 0 AND m.message_date < $14::int)
OR ($14::int <= 0 AND ($15::int <= 0 OR m.box_id < $15::int))
)
ORDER BY m.box_id DESC
OFFSET GREATEST($14::int, 0)
LIMIT $15::int
OFFSET GREATEST($16::int, 0)
LIMIT $17::int
`
type ListMessagesBackwardParams struct {
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
OffsetDate int32
OffsetID int32
RowOffset int32
LimitCount int32
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
OffsetDate int32
OffsetID int32
RowOffset int32
LimitCount int32
}
type ListMessagesBackwardRow struct {
@ -2416,6 +2430,8 @@ func (q *Queries) ListMessagesBackward(ctx context.Context, arg ListMessagesBack
arg.HasPeer,
arg.PeerType,
arg.PeerID,
arg.RestrictPeerIds,
arg.PeerIds,
arg.Query,
arg.MaxID,
arg.MinID,
@ -2618,14 +2634,18 @@ base AS NOT MATERIALIZED (
OR (m.peer_type = $7::text AND m.peer_id = $8::bigint)
)
AND (
$9::text = ''
OR m.body ILIKE ('%' || $9::text || '%')
NOT $9::boolean
OR (m.peer_type = 'user' AND m.peer_id = ANY($10::bigint[]))
)
AND ($10::int <= 0 OR m.box_id < $10::int)
AND ($11::int <= 0 OR m.box_id > $11::int)
AND (NOT $12::boolean OR m.pinned)
AND (
NOT $13::boolean
$11::text = ''
OR m.body ILIKE ('%' || $11::text || '%')
)
AND ($12::int <= 0 OR m.box_id < $12::int)
AND ($13::int <= 0 OR m.box_id > $13::int)
AND (NOT $14::boolean OR m.pinned)
AND (
NOT $15::boolean
OR (
m.media->>'kind' = 'document'
AND EXISTS (
@ -2637,14 +2657,14 @@ base AS NOT MATERIALIZED (
)
)
AND (
$14::text = ''
OR (m.saved_peer_type = $14::text AND m.saved_peer_id = $15::bigint)
$16::text = ''
OR (m.saved_peer_type = $16::text AND m.saved_peer_id = $17::bigint)
)
),
total AS (
SELECT count(*)::int AS total_count
FROM base
WHERE $16::boolean
WHERE $18::boolean
),
backward AS (
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
@ -2791,22 +2811,24 @@ ORDER BY box_id DESC
`
type ListMessagesByUserParams struct {
OwnerUserID int64
OffsetID int32
OffsetDate int32
AddOffset int32
LimitCount int32
HasPeer bool
PeerType string
PeerID int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
NeedTotalCount bool
OwnerUserID int64
OffsetID int32
OffsetDate int32
AddOffset int32
LimitCount int32
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
NeedTotalCount bool
}
type ListMessagesByUserRow struct {
@ -2896,6 +2918,8 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser
arg.HasPeer,
arg.PeerType,
arg.PeerID,
arg.RestrictPeerIds,
arg.PeerIds,
arg.Query,
arg.MaxID,
arg.MinID,

View file

@ -8,6 +8,32 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
type AccountDeletionNotification struct {
ID int64
TargetUserID int64
DeletedUserID int64
Status string
Attempts int32
NextAttemptAt pgtype.Timestamptz
LeaseUntil pgtype.Timestamptz
LastError string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type AccountDeletionRequest struct {
ID int64
UserID int64
RequesterAuthKeyID int64
State string
Reason string
ConfirmHashDigest []byte
RequestedAt pgtype.Timestamptz
ExecuteAt pgtype.Timestamptz
CompletedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type AccountPassword struct {
UserID int64
HasRecovery bool
@ -30,6 +56,7 @@ type AccountPassword struct {
RecoveryCode string
RecoveryCodeExpiresAt pgtype.Timestamptz
LoginEmail string
PasswordChangedAt pgtype.Timestamptz
}
type AccountPrivacyRule struct {
@ -267,21 +294,49 @@ type Bot struct {
}
type BotApiUpdate struct {
ID int64
BotUserID int64
UpdateKind string
PeerType string
PeerID int64
MessageID int32
SourcePts int32
Date int32
CreatedAt pgtype.Timestamptz
ID int64
BotUserID int64
UpdateKind string
PeerType string
PeerID int64
MessageID int32
SourcePts int32
Date int32
CreatedAt pgtype.Timestamptz
CallbackQueryID int64
CallbackUserID int64
CallbackChatInstance int64
CallbackData []byte
CallbackInlineDcID int32
CallbackInlineOwnerID int64
CallbackInlineMessageID int32
CallbackInlineAccessHash int64
EphemeralPayload []byte
}
type BotApiUpdateState struct {
BotUserID int64
ConfirmedUpdateID int64
UpdatedAt pgtype.Timestamptz
AllowedUpdates []string
CursorInitialized bool
PollOwner string
PollExpiresAt pgtype.Timestamptz
}
type BotApiWebhook struct {
BotUserID int64
Url string
SecretToken string
MaxConnections int32
AllowedUpdates []string
FailureCount int32
LastErrorDate int32
LastErrorMessage string
NextAttemptAt pgtype.Timestamptz
DeliveryOwner string
DeliveryExpiresAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type BotApp struct {
@ -448,6 +503,7 @@ type Channel struct {
LinkedMonoforumID int64
Wallpaper []byte
Verified bool
LinkedCommunityID int64
}
type ChannelAdminLogEvent struct {
@ -653,6 +709,8 @@ type ChannelMessage struct {
DeleteDate int32
DeleteMessageIds []byte
RequestFingerprint []byte
SuggestedPost []byte
PaidMessageStars int64
}
type ChannelMessageMedium struct {
@ -694,6 +752,42 @@ type ChannelMessageViewer struct {
CreatedAt pgtype.Timestamptz
}
type ChannelStarsBalance struct {
ChannelID int64
Balance int64
UpdatedAt pgtype.Timestamptz
}
type ChannelStarsTransaction struct {
ID int64
ChannelID int64
ActorUserID int64
Amount int64
Reason string
PeerType string
PeerID int64
GiftID *int64
Date int32
}
type ChannelTonBalance struct {
ChannelID int64
BalanceNanoton int64
UpdatedAt pgtype.Timestamptz
}
type ChannelTonTransaction struct {
ID int64
ChannelID int64
ActorUserID int64
AmountNanoton int64
Reason string
PeerType string
PeerID int64
GiftID *int64
Date int32
}
type ChannelTopicRead struct {
ChannelID int64
UserID int64
@ -767,6 +861,62 @@ type ChatlistMembership struct {
UpdatedAt pgtype.Timestamptz
}
type Community struct {
ID int64
AccessHash int64
CreatorUserID int64
Title string
About string
DefaultBannedRights []byte
PhotoID int64
PhotoDcID int32
PhotoStripped []byte
Date int32
Deleted bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type CommunityMember struct {
CommunityID int64
UserID int64
Role string
Status string
AdminRights []byte
Rank string
Date int32
UpdatedAt pgtype.Timestamptz
}
type CommunityPeerLink struct {
CommunityID int64
PeerType string
PeerID int64
Visibility string
CreatedBy int64
Date int32
CreatedAt pgtype.Timestamptz
}
type CommunityPeerLinkRequest struct {
CommunityID int64
PeerType string
PeerID int64
RequestedBy int64
Visibility string
Date int32
CreatedAt pgtype.Timestamptz
}
type CommunityUserState struct {
CommunityID int64
UserID int64
Collapsed bool
Pinned bool
PinnedOrder int32
UpdatedAt pgtype.Timestamptz
}
type Contact struct {
UserID int64
ContactUserID int64
@ -942,6 +1092,21 @@ type EncryptedStateEventDelivery struct {
AuthKeyID int64
}
type EphemeralAbuseReport struct {
ID int64
ReporterUserID int64
ChannelID int64
EphemeralMessageID int32
SenderUserID int64
ReceiverUserID int64
ReportOption string
ReportComment string
CommentHash []byte
PayloadHash []byte
Evidence []byte
CreatedAt pgtype.Timestamptz
}
type FileBlob struct {
LocationKey string
Backend string
@ -1156,19 +1321,33 @@ type PasskeyCredential struct {
}
type PeerStarGift struct {
ID int64
OwnerPeerID int64
FromUserID int64
GiftID int64
MsgID int32
GiftDate int32
NameHidden bool
Unsaved bool
Converted bool
ConvertStars int64
Message string
OwnerPeerType string
SavedID int64
ID int64
OwnerPeerID int64
FromUserID int64
GiftID int64
MsgID int32
GiftDate int32
NameHidden bool
Unsaved bool
Converted bool
ConvertStars int64
Message string
OwnerPeerType string
SavedID int64
CatalogRevisionID int64
UniqueGiftID *int64
UpgradeMsgID int32
PinnedOrder int32
PrepaidUpgradeStars int64
LifecycleStatus string
TransferStars int64
PrepaidUpgradeHash string
GiftNum int32
CanExportAt int32
CanTransferAt int32
CanResellAt int32
DropOriginalDetailsStars int64
CanCraftAt int32
}
type PeerTranslationSetting struct {
@ -1417,6 +1596,394 @@ type SeedState struct {
UpdatedAt pgtype.Timestamptz
}
type StarGiftAuction struct {
GiftID int64
Slug string
Version int32
StartDate int32
EndDate int32
RoundDuration int32
GiftsPerRound int32
TotalRounds int32
CurrentRound int32
NextRoundAt int32
LastGiftNum int32
GiftsLeft int32
MinBidAmount int64
Status string
UpdatedAt pgtype.Timestamptz
}
type StarGiftAuctionAcquired struct {
ID int64
GiftID int64
BidderUserID int64
RecipientPeerType string
RecipientPeerID int64
SavedGiftID *int64
BidAmount int64
Round int32
Pos int32
GiftNum *int32
AcquiredAt int32
HideName bool
Message string
}
type StarGiftAuctionBid struct {
GiftID int64
BidderUserID int64
RecipientPeerType string
RecipientPeerID int64
Amount int64
BidDate int32
HideName bool
Message string
Returned bool
AcquiredCount int32
Active bool
Version int64
}
type StarGiftAuctionBidPayment struct {
UserID int64
FormID int64
GiftID int64
BidAmount int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftCatalog struct {
GiftID int64
ActiveRevisionID int64
Enabled bool
SortOrder int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
CollectibleRevisionID *int64
AvailabilityRemains int32
AvailabilityResale int64
ResellMinStars int64
FirstSaleDate int32
LastSaleDate int32
}
type StarGiftCatalogRevision struct {
ID int64
GiftID int64
Revision int32
Title string
Stars int64
ConvertStars int64
DocumentID int64
AnimationJson []byte
AnimationSha256 []byte
SourceName string
SourceFormat string
Width int32
Height int32
FrameRate float64
InPoint float64
OutPoint float64
CreatedBy string
CommandID string
CreatedAt pgtype.Timestamptz
OfficialGiftID *int64
SourceManifestSha256 []byte
OfficialSource []byte
Limited bool
SoldOut bool
Birthday bool
RequirePremium bool
LimitedPerUser bool
PeerColorAvailable bool
Auction bool
AvailabilityTotal int32
ReleasedByPeerType *string
ReleasedByPeerID *int64
PerUserTotal int32
LockedUntilDate int32
AuctionSlug string
GiftsPerRound int32
AuctionStartDate int32
UpgradeVariants int32
BackgroundCenterColor *int32
BackgroundEdgeColor *int32
BackgroundTextColor *int32
}
type StarGiftCollectibleBackdrop struct {
ID int64
CollectibleRevisionID int64
Name string
BackdropID int32
CenterColor int32
EdgeColor int32
PatternColor int32
TextColor int32
RarityPermille *int32
SortOrder int32
RarityKind string
}
type StarGiftCollectibleModel struct {
ID int64
CollectibleRevisionID int64
Name string
DocumentID int64
AnimationJson []byte
AnimationSha256 []byte
SourceName string
SourceFormat string
Width int32
Height int32
FrameRate float64
InPoint float64
OutPoint float64
RarityPermille *int32
SortOrder int32
RarityKind string
Crafted bool
OfficialDocumentID *int64
}
type StarGiftCollectiblePattern struct {
ID int64
CollectibleRevisionID int64
Name string
DocumentID int64
AnimationJson []byte
AnimationSha256 []byte
SourceName string
SourceFormat string
Width int32
Height int32
FrameRate float64
InPoint float64
OutPoint float64
RarityPermille *int32
SortOrder int32
RarityKind string
OfficialDocumentID *int64
}
type StarGiftCollectibleRevision struct {
ID int64
GiftID int64
Revision int32
UpgradeStars int64
SupplyTotal int32
Issued int32
SlugPrefix string
Status string
CreatedBy string
CommandID string
CreatedAt pgtype.Timestamptz
PublishedAt pgtype.Timestamptz
OfficialGiftID *int64
SourceManifestSha256 []byte
}
type StarGiftCollection struct {
CollectionID int32
OwnerPeerType string
OwnerPeerID int64
Title string
SortOrder int32
Hash int64
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type StarGiftCollectionItem struct {
CollectionID int32
SavedGiftID int64
SortOrder int32
CreatedAt pgtype.Timestamptz
}
type StarGiftConversion struct {
SavedGiftID int64
ActorUserID int64
OwnerPeerType string
OwnerPeerID int64
Amount int64
BalanceAfter int64
ConvertedAt int32
}
type StarGiftCraftCommand struct {
UserID int64
CommandKey string
InputUniqueGiftIds []int64
GiftID int64
Success bool
ResultUniqueGiftID *int64
ChancePermille int32
CreatedAt int32
SourceEditPts []int32
}
type StarGiftDropDetailsCommand struct {
UserID int64
CommandKey string
SavedGiftID int64
UniqueGiftID int64
FormID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftListing struct {
UniqueGiftID int64
SellerPeerType string
SellerPeerID int64
Currency string
Amount int64
ListedAt int32
UpdatedAt int32
Version int64
}
type StarGiftNotificationSetting struct {
UserID int64
ChannelID int64
Enabled bool
UpdatedAt pgtype.Timestamptz
}
type StarGiftOffer struct {
ID int64
BuyerUserID int64
OwnerPeerType string
OwnerPeerID int64
UniqueGiftID int64
Currency string
Amount int64
RandomID int64
OfferMsgID int32
BuyerMsgID int32
Status string
CreatedAt int32
ExpiresAt int32
ResolvedAt int32
BalanceAfter int64
ResolutionNotified bool
}
type StarGiftPatternDocumentRepair struct {
OldDocumentID int64
NewDocumentID int64
RepairedAt pgtype.Timestamptz
}
type StarGiftPatternPreviewDocumentRepair struct {
OldDocumentID int64
NewDocumentID int64
RepairedAt pgtype.Timestamptz
}
type StarGiftPrepaidUpgradeCommand struct {
PayerUserID int64
CommandKey string
SavedGiftID int64
FormID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftPurchaseCommand struct {
BuyerUserID int64
CommandKey string
GiftID int64
RecipientPeerType string
RecipientPeerID int64
SavedGiftID int64
FormID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftPurchaseForm struct {
BuyerUserID int64
FormID int64
GiftID int64
RevisionID int64
RecipientPeerType string
RecipientPeerID int64
IncludeUpgrade bool
HideName bool
Message string
ChargeStars int64
IssuedAt int32
ExpiresAt int32
}
type StarGiftSale struct {
ID int64
UniqueGiftID int64
SellerPeerType string
SellerPeerID int64
BuyerPeerType string
BuyerPeerID int64
Currency string
Amount int64
CommissionAmount int64
SoldAt int32
CommandKey string
}
type StarGiftTransferCommand struct {
ActorUserID int64
CommandKey string
UniqueGiftID int64
FromPeerType string
FromPeerID int64
ToPeerType string
ToPeerID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftUpgradeCommand struct {
UserID int64
CommandKey string
SourceSavedGiftID int64
FormID int64
UniqueGiftID int64
BalanceAfter int64
CreatedAt pgtype.Timestamptz
ChargeStars int64
RequirePrepaid bool
KeepOriginalDetails bool
SourceEditPts int32
}
type StarGiftUserPurchase struct {
UserID int64
GiftID int64
PurchasedCount int32
UpdatedAt pgtype.Timestamptz
}
type StarGiftWithdrawalRequest struct {
ID int64
UniqueGiftID int64
OwnerUserID int64
Provider string
ProviderRequestID string
Url string
Status string
CreatedAt int32
ExpiresAt int32
CompletedAt int32
}
type StarsBalance struct {
UserID int64
Balance int64
@ -1533,6 +2100,28 @@ type StoryView struct {
UpdatedAt pgtype.Timestamptz
}
type TelesrvCollectiblePatternCorrectionEvent struct {
UserID int64
Pts int32
}
type TelesrvPatternPreviewCorrectionEvent struct {
UserID int64
Pts int32
}
type TelesrvPatternPreviewRepairedWearer struct {
UserID int64
OldDocumentID int64
NewDocumentID int64
}
type TelesrvRepairedCollectibleWearer struct {
UserID int64
OldDocumentID int64
NewDocumentID int64
}
type TempAuthKeyBinding struct {
TempAuthKeyID int64
PermAuthKeyID int64
@ -1564,6 +2153,66 @@ type ThemeUserInstall struct {
InstalledAt pgtype.Timestamptz
}
type TonBalance struct {
UserID int64
BalanceNanoton int64
Granted bool
UpdatedAt pgtype.Timestamptz
}
type TonTransaction struct {
ID int64
UserID int64
AmountNanoton int64
Reason string
PeerType *string
PeerID *int64
GiftID *int64
Date int32
}
type UniqueStarGift struct {
ID int64
GiftID int64
CollectibleRevisionID int64
SourceSavedGiftID int64
Title string
Slug string
Num int32
OwnerPeerType *string
OwnerPeerID *int64
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
KeepOriginalDetails bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
RequirePremium bool
ResaleTonOnly bool
ThemeAvailable bool
Burned bool
Crafted bool
OriginalOwnerPeerType string
OriginalOwnerPeerID int64
OwnerName string
OwnerAddress string
GiftAddress string
ReleasedByPeerType *string
ReleasedByPeerID *int64
ValueAmount int64
ValueCurrency string
ValueUsdAmount int64
ThemePeerType *string
ThemePeerID *int64
HostPeerType *string
HostPeerID *int64
OfferMinStars int32
CraftChancePermille int32
LastSaleDate int32
LastSaleCurrency string
LastSaleAmount int64
}
type UpdateState struct {
AuthKeyID int64
Pts int32
@ -1627,6 +2276,13 @@ type User struct {
BirthdayMonth int32
BirthdayYear int32
PersonalChannelID int64
DeletedAt pgtype.Timestamptz
DeletionSource string
DeletionReason string
AccountDeleteAt pgtype.Timestamptz
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
LinkedCommunityID int64
SignupEmail string
}
@ -1703,33 +2359,34 @@ type UserTopReaction struct {
}
type UserUpdateEvent struct {
UserID int64
Pts int32
PtsCount int32
Date int32
EventType string
MessageBoxID *int32
PeerType *string
PeerID *int64
MaxID int32
StillUnreadCount int32
CreatedAt pgtype.Timestamptz
EventBool bool
EventPeers []byte
PeerSettings []byte
MessageIds []byte
DialogFilter []byte
FilterOrder []byte
FolderPeers []byte
FilterID int32
TagsEnabled bool
ChannelPts int32
FolderID int32
QuickReplies []byte
QuickReplyMessage []byte
StoryPayload []byte
ReactionPayload []byte
EventPhone string
UserID int64
Pts int32
PtsCount int32
Date int32
EventType string
MessageBoxID *int32
PeerType *string
PeerID *int64
MaxID int32
StillUnreadCount int32
CreatedAt pgtype.Timestamptz
EventBool bool
EventPeers []byte
PeerSettings []byte
MessageIds []byte
DialogFilter []byte
FilterOrder []byte
FolderPeers []byte
FilterID int32
TagsEnabled bool
ChannelPts int32
FolderID int32
QuickReplies []byte
QuickReplyMessage []byte
StoryPayload []byte
ReactionPayload []byte
EventPhone string
EmojiStatusPayload []byte
}
type UserUpdateRetention struct {
@ -1765,13 +2422,17 @@ type WebviewCustomMethodQuery struct {
}
type WebviewRequestedButton struct {
WebappReqID string
BotUserID int64
UserID int64
ButtonID int32
Text string
PeerType string
MaxQuantity int32
CreatedAt pgtype.Timestamptz
ExpiresAt pgtype.Timestamptz
WebappReqID string
BotUserID int64
UserID int64
ButtonID int32
Text string
PeerType string
MaxQuantity int32
CreatedAt pgtype.Timestamptz
ExpiresAt pgtype.Timestamptz
PeerFilter []byte
NameRequested bool
UsernameRequested bool
PhotoRequested bool
}

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, 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, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
`
type CreateUserParams struct {
@ -70,13 +70,20 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
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, 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, 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
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
@ -112,13 +119,20 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
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, signup_email FROM users WHERE phone = $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, 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
`
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
@ -154,13 +168,20 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
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, 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, 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 <> ''
`
func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User, error) {
@ -196,13 +217,20 @@ func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User,
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
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, signup_email FROM users WHERE lower(username) = lower($1) AND username <> ''
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
`
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
@ -238,13 +266,20 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
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, 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, 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 = ANY($1::bigint[])
ORDER BY id
@ -289,6 +324,13 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
); err != nil {
return nil, err
@ -302,9 +344,9 @@ 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, 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, 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 = ANY($1::text[])
WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
ORDER BY id
`
@ -347,6 +389,13 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
); err != nil {
return nil, err
@ -377,12 +426,15 @@ WITH matched AS (
u.premium_expires_at,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.color_set,
u.color,
u.color_background_emoji_id,
u.profile_color_set,
u.profile_color,
u.profile_color_background_emoji_id,
u.linked_community_id,
u.last_seen_at,
(c.contact_user_id IS NOT NULL)::boolean AS contact,
COALESCE(c.mutual, false)::boolean AS mutual,
@ -397,6 +449,7 @@ WITH matched AS (
FROM users u
LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id
WHERE u.id <> $4::bigint
AND u.deleted_at IS NULL
AND $3::text <> ''
AND (
($2::text <> '' AND u.phone LIKE $2::text || '%')
@ -425,12 +478,15 @@ SELECT
premium_expires_at,
emoji_status_document_id,
emoji_status_until,
emoji_status_collectible_id,
emoji_status_collectible,
color_set,
color,
color_background_emoji_id,
profile_color_set,
profile_color,
profile_color_background_emoji_id,
linked_community_id,
last_seen_at,
contact,
mutual
@ -463,12 +519,15 @@ type SearchUsersRow struct {
PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64
EmojiStatusUntil int64
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
ColorSet bool
Color int32
ColorBackgroundEmojiID int64
ProfileColorSet bool
ProfileColor int32
ProfileColorBackgroundEmojiID int64
LinkedCommunityID int64
LastSeenAt int64
Contact bool
Mutual bool
@ -505,12 +564,15 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Sea
&i.PremiumExpiresAt,
&i.EmojiStatusDocumentID,
&i.EmojiStatusUntil,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.ColorSet,
&i.Color,
&i.ColorBackgroundEmojiID,
&i.ProfileColorSet,
&i.ProfileColor,
&i.ProfileColorBackgroundEmojiID,
&i.LinkedCommunityID,
&i.LastSeenAt,
&i.Contact,
&i.Mutual,
@ -529,8 +591,8 @@ const setUserPremiumUntil = `-- name: SetUserPremiumUntil :one
UPDATE users
SET premium_expires_at = $1::timestamptz,
updated_at = now()
WHERE id = $2::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, signup_email
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
`
type SetUserPremiumUntilParams struct {
@ -571,6 +633,13 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err
@ -580,8 +649,8 @@ const setUserVerified = `-- name: SetUserVerified :one
UPDATE users
SET verified = $1::boolean,
updated_at = now()
WHERE id = $2::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, signup_email
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
`
type SetUserVerifiedParams struct {
@ -622,6 +691,13 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err
@ -634,11 +710,12 @@ SET premium_expires_at = NULL,
WHERE id IN (
SELECT id FROM users
WHERE premium_expires_at IS NOT NULL
AND deleted_at IS NULL
AND premium_expires_at <= $1::timestamptz
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, 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, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
`
type SweepExpiredPremiumParams struct {
@ -685,6 +762,13 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
); err != nil {
return nil, err
@ -703,8 +787,8 @@ SET birthday_day = $1::int,
birthday_month = $2::int,
birthday_year = $3::int,
updated_at = now()
WHERE id = $4::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, signup_email
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
`
type UpdateUserBirthdayParams struct {
@ -752,6 +836,13 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err
@ -763,8 +854,8 @@ SET color_set = $1::boolean,
color = $2::int,
color_background_emoji_id = $3::bigint,
updated_at = now()
WHERE id = $4::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, signup_email
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
`
type UpdateUserColorParams struct {
@ -812,6 +903,13 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err
@ -821,19 +919,29 @@ const updateUserEmojiStatus = `-- name: UpdateUserEmojiStatus :one
UPDATE users
SET emoji_status_document_id = $1::bigint,
emoji_status_until = $2::bigint,
emoji_status_collectible_id = $3::bigint,
emoji_status_collectible = $4::jsonb,
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, signup_email
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
`
type UpdateUserEmojiStatusParams struct {
EmojiStatusDocumentID int64
EmojiStatusUntil int64
ID int64
EmojiStatusDocumentID int64
EmojiStatusUntil int64
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
ID int64
}
func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmojiStatusParams) (User, error) {
row := q.db.QueryRow(ctx, updateUserEmojiStatus, arg.EmojiStatusDocumentID, arg.EmojiStatusUntil, arg.ID)
row := q.db.QueryRow(ctx, updateUserEmojiStatus,
arg.EmojiStatusDocumentID,
arg.EmojiStatusUntil,
arg.EmojiStatusCollectibleID,
arg.EmojiStatusCollectible,
arg.ID,
)
var i User
err := row.Scan(
&i.ID,
@ -865,6 +973,13 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err
@ -874,7 +989,7 @@ const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec
UPDATE users
SET last_seen_at = GREATEST(last_seen_at, $1::bigint),
updated_at = now()
WHERE id = $2::bigint
WHERE id = $2::bigint AND deleted_at IS NULL
`
type UpdateUserLastSeenParams struct {
@ -891,8 +1006,8 @@ const updateUserPersonalChannel = `-- name: UpdateUserPersonalChannel :one
UPDATE users
SET personal_channel_id = $1::bigint,
updated_at = now()
WHERE id = $2::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, signup_email
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
`
type UpdateUserPersonalChannelParams struct {
@ -933,6 +1048,13 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err
@ -942,8 +1064,8 @@ const updateUserPhone = `-- name: UpdateUserPhone :one
UPDATE users
SET phone = $1::text,
updated_at = now()
WHERE id = $2::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, signup_email
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
`
type UpdateUserPhoneParams struct {
@ -984,6 +1106,13 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err
@ -995,7 +1124,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, 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, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
`
type UpdateUserPhoneAndSignupEmailParams struct {
@ -1037,6 +1166,13 @@ func (q *Queries) UpdateUserPhoneAndSignupEmail(ctx context.Context, arg UpdateU
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err
@ -1048,8 +1184,8 @@ SET first_name = $2,
last_name = $3,
about = $4,
updated_at = now()
WHERE id = $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, signup_email
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
`
type UpdateUserProfileParams struct {
@ -1097,6 +1233,13 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err
@ -1108,8 +1251,8 @@ SET profile_color_set = $1::boolean,
profile_color = $2::int,
profile_color_background_emoji_id = $3::bigint,
updated_at = now()
WHERE id = $4::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, signup_email
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
`
type UpdateUserProfileColorParams struct {
@ -1157,6 +1300,13 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err
@ -1166,8 +1316,8 @@ const updateUserUsername = `-- name: UpdateUserUsername :one
UPDATE users
SET username = $2,
updated_at = now()
WHERE id = $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, signup_email
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
`
type UpdateUserUsernameParams struct {
@ -1208,6 +1358,13 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.SignupEmail,
)
return i, err

View file

@ -26,6 +26,7 @@ INSERT INTO user_update_events (
folder_peers,
story_payload,
reaction_payload,
emoji_status_payload,
message_box_id,
peer_type,
peer_id,
@ -51,43 +52,45 @@ INSERT INTO user_update_events (
$13::jsonb,
$14::jsonb,
$15::jsonb,
$16,
$17::text,
$18::bigint,
$19::int,
$16::jsonb,
$17,
$18::text,
$19::bigint,
$20::int,
$21::int,
$22::int,
$23::boolean,
$24::int
$23::int,
$24::boolean,
$25::int
)
`
type AppendUserUpdateEventParams struct {
UserID int64
Pts int32
PtsCount int32
Date int32
EventType string
EventBool bool
EventPhone string
EventPeers []byte
PeerSettings []byte
MessageIds []byte
DialogFilter []byte
FilterOrder []byte
FolderPeers []byte
StoryPayload []byte
ReactionPayload []byte
MessageBoxID *int32
PeerType *string
PeerID *int64
FilterID int32
MaxID int32
StillUnreadCount int32
ChannelPts int32
TagsEnabled bool
FolderID int32
UserID int64
Pts int32
PtsCount int32
Date int32
EventType string
EventBool bool
EventPhone string
EventPeers []byte
PeerSettings []byte
MessageIds []byte
DialogFilter []byte
FilterOrder []byte
FolderPeers []byte
StoryPayload []byte
ReactionPayload []byte
EmojiStatusPayload []byte
MessageBoxID *int32
PeerType *string
PeerID *int64
FilterID int32
MaxID int32
StillUnreadCount int32
ChannelPts int32
TagsEnabled bool
FolderID int32
}
func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdateEventParams) error {
@ -107,6 +110,7 @@ func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdat
arg.FolderPeers,
arg.StoryPayload,
arg.ReactionPayload,
arg.EmojiStatusPayload,
arg.MessageBoxID,
arg.PeerType,
arg.PeerID,
@ -137,6 +141,7 @@ SELECT
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
COALESCE(e.peer_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id,
@ -274,6 +279,7 @@ type BatchListDispatchEventsRow struct {
FolderPeersJson string
StoryPayloadJson string
ReactionPayloadJson string
EmojiStatusPayloadJson string
EventPeerType string
EventPeerID int64
FilterID int32
@ -409,6 +415,7 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp
&i.FolderPeersJson,
&i.StoryPayloadJson,
&i.ReactionPayloadJson,
&i.EmojiStatusPayloadJson,
&i.EventPeerType,
&i.EventPeerID,
&i.FilterID,
@ -788,6 +795,7 @@ SELECT
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
COALESCE(e.peer_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id,
@ -928,6 +936,7 @@ type ListUserUpdateEventsAfterRow struct {
FolderPeersJson string
StoryPayloadJson string
ReactionPayloadJson string
EmojiStatusPayloadJson string
EventPeerType string
EventPeerID int64
FilterID int32
@ -1061,6 +1070,7 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd
&i.FolderPeersJson,
&i.StoryPayloadJson,
&i.ReactionPayloadJson,
&i.EmojiStatusPayloadJson,
&i.EventPeerType,
&i.EventPeerID,
&i.FilterID,

View file

@ -24,6 +24,16 @@ func NewStarGiftStore(db sqlcgen.DBTX) *StarGiftStore {
const starGiftCatalogSelect = `
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title,
r.limited, r.sold_out, r.birthday, r.require_premium,
r.limited_per_user, r.peer_color_available, r.auction,
c.availability_remains, r.availability_total, c.availability_resale,
c.first_sale_date, c.last_sale_date, c.resell_min_stars,
COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0),
r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round,
r.auction_start_date, r.upgrade_variants,
r.background_center_color IS NOT NULL,
COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0),
COALESCE(r.background_text_color, 0),
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
@ -75,6 +85,16 @@ func (s *StarGiftStore) CatalogRevision(ctx context.Context, revisionID int64) (
}
gift, err := scanCatalogGift(s.db.QueryRow(ctx, `
SELECT r.gift_id, r.id, r.stars, r.convert_stars, r.title,
r.limited, r.sold_out, r.birthday, r.require_premium,
r.limited_per_user, r.peer_color_available, r.auction,
c.availability_remains, r.availability_total, c.availability_resale,
c.first_sale_date, c.last_sale_date, c.resell_min_stars,
COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0),
r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round,
r.auction_start_date, r.upgrade_variants,
r.background_center_color IS NOT NULL,
COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0),
COALESCE(r.background_text_color, 0),
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
@ -95,14 +115,34 @@ WHERE r.id = $1`, revisionID))
func scanCatalogGift(row rowScanner) (domain.StarGift, error) {
var gift domain.StarGift
var attrsJSON, thumbsJSON string
var releasedByType string
var releasedByID int64
var hasBackground bool
var background domain.StarGiftBackground
if err := row.Scan(
&gift.ID, &gift.RevisionID, &gift.Stars, &gift.ConvertStars, &gift.Title,
&gift.Limited, &gift.SoldOut, &gift.Birthday, &gift.RequirePremium,
&gift.LimitedPerUser, &gift.PeerColorAvailable, &gift.Auction,
&gift.AvailabilityRemains, &gift.AvailabilityTotal, &gift.AvailabilityResale,
&gift.FirstSaleDate, &gift.LastSaleDate, &gift.ResellMinStars,
&releasedByType, &releasedByID, &gift.PerUserTotal, &gift.LockedUntilDate,
&gift.AuctionSlug, &gift.GiftsPerRound, &gift.AuctionStartDate, &gift.UpgradeVariants,
&hasBackground, &background.CenterColor, &background.EdgeColor, &background.TextColor,
&gift.UpgradeStars, &gift.UpgradeTotal, &gift.UpgradeIssued,
&gift.Sticker.ID, &gift.Sticker.AccessHash, &gift.Sticker.FileReference, &gift.Sticker.Date,
&gift.Sticker.MimeType, &gift.Sticker.Size, &gift.Sticker.DCID, &attrsJSON, &thumbsJSON,
); err != nil {
return domain.StarGift{}, err
}
if releasedByType != "" && releasedByID > 0 {
gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID}
}
if hasBackground {
gift.Background = &background
}
if gift.LimitedPerUser {
gift.PerUserRemains = gift.PerUserTotal
}
attrs, err := decodeDocumentAttributes(attrsJSON)
if err != nil {
return domain.StarGift{}, fmt.Errorf("decode star gift document attributes: %w", err)
@ -148,8 +188,12 @@ func (s *StarGiftStore) CreateCatalogRevision(ctx context.Context, write domain.
return fmt.Errorf("allocate star gift id: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_catalog (gift_id, active_revision_id, enabled, sort_order)
VALUES ($1,$2,$3,$4)`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil {
INSERT INTO star_gift_catalog (
gift_id, active_revision_id, enabled, sort_order, availability_remains,
availability_resale, resell_min_stars, first_sale_date, last_sale_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, giftID, revisionID, write.Enabled, write.SortOrder,
write.AvailabilityRemains, write.AvailabilityResale, write.ResellMinStars,
write.FirstSaleDate, write.LastSaleDate); err != nil {
return fmt.Errorf("insert star gift catalog: %w", err)
}
} else {
@ -180,20 +224,38 @@ WHERE gift_id = $1`, giftID).Scan(&revision); err != nil {
INSERT INTO star_gift_catalog_revisions (
id, gift_id, revision, title, stars, convert_stars, document_id,
animation_json, animation_sha256, source_name, source_format,
width, height, frame_rate, in_point, out_point, created_by, command_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`,
width, height, frame_rate, in_point, out_point, created_by, command_id,
official_gift_id, source_manifest_sha256, official_source,
limited, sold_out, birthday, require_premium, limited_per_user,
peer_color_available, auction, availability_total,
released_by_peer_type, released_by_peer_id, per_user_total, locked_until_date,
auction_slug, gifts_per_round, auction_start_date, upgrade_variants,
background_center_color, background_edge_color, background_text_color
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
NULLIF($19::bigint,0),$20,$21::jsonb,$22,$23,$24,$25,$26,$27,$28,$29,
$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40
)`,
revisionID, giftID, revision, write.Title, write.Stars, write.ConvertStars, write.Document.ID,
string(write.Animation.JSON), write.Animation.SHA256, write.Animation.SourceName, string(write.Animation.SourceFormat),
write.Animation.Width, write.Animation.Height, write.Animation.FrameRate, write.Animation.InPoint, write.Animation.OutPoint,
write.Actor, write.CommandID,
write.Actor, write.CommandID, write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256), nullableOfficialGiftJSON(write.OfficialSourceJSON),
write.Limited, write.SoldOut, write.Birthday, write.RequirePremium, write.LimitedPerUser,
write.PeerColorAvailable, write.Auction, write.AvailabilityTotal,
nullableStarGiftPeerType(write.ReleasedBy), nullableStarGiftPeerID(write.ReleasedBy), write.PerUserTotal,
write.LockedUntilDate, write.AuctionSlug, write.GiftsPerRound, write.AuctionStartDate,
write.UpgradeVariants, nullableBackgroundColor(write.Background, "center"),
nullableBackgroundColor(write.Background, "edge"), nullableBackgroundColor(write.Background, "text"),
); err != nil {
return fmt.Errorf("insert star gift revision: %w", err)
}
if write.GiftID != 0 {
if _, err := tx.Exec(ctx, `
UPDATE star_gift_catalog
SET active_revision_id=$2, enabled=$3, sort_order=$4, updated_at=now()
WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil {
SET active_revision_id=$2, enabled=$3, sort_order=$4, availability_remains=$5,
availability_resale=$6, resell_min_stars=$7, first_sale_date=$8, last_sale_date=$9, updated_at=now()
WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder, write.AvailabilityRemains,
write.AvailabilityResale, write.ResellMinStars, write.FirstSaleDate, write.LastSaleDate); err != nil {
return fmt.Errorf("activate star gift revision: %w", err)
}
}
@ -208,6 +270,62 @@ WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != n
return entry, nil
}
func nullableStarGiftPeerType(peer domain.Peer) any {
if peer.ID <= 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
return nil
}
return string(peer.Type)
}
func nullableStarGiftPeerID(peer domain.Peer) any {
if nullableStarGiftPeerType(peer) == nil {
return nil
}
return peer.ID
}
func nullableBackgroundColor(background *domain.StarGiftBackground, component string) any {
if background == nil {
return nil
}
switch component {
case "center":
return background.CenterColor
case "edge":
return background.EdgeColor
default:
return background.TextColor
}
}
func (s *StarGiftStore) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
var result domain.StarGiftCatalogBundleResult
err := withTx(ctx, s.db, "create star gift catalog bundle", func(tx pgx.Tx) error {
nested := NewStarGiftStore(tx)
entry, err := nested.CreateCatalogRevision(ctx, write.Catalog)
if err != nil {
return err
}
result.Catalog = entry
if write.Collectible != nil {
collectibleWrite := *write.Collectible
collectibleWrite.GiftID = entry.Gift.ID
revision, err := nested.PublishCollectibleRevision(ctx, collectibleWrite)
if err != nil {
return err
}
result.Collectible = &revision
entry, err = catalogEntryByID(ctx, tx, entry.Gift.ID)
if err != nil {
return err
}
result.Catalog = entry
}
return nil
})
return result, err
}
func (s *StarGiftStore) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE star_gift_catalog SET enabled=$2, updated_at=now()
@ -267,6 +385,16 @@ WHERE c.gift_id=$1`, giftID).Scan(&raw)
func catalogEntryByID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (domain.StarGiftCatalogEntry, error) {
row := db.QueryRow(ctx, `
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title,
r.limited, r.sold_out, r.birthday, r.require_premium,
r.limited_per_user, r.peer_color_available, r.auction,
c.availability_remains, r.availability_total, c.availability_resale,
c.first_sale_date, c.last_sale_date, c.resell_min_stars,
COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0),
r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round,
r.auction_start_date, r.upgrade_variants,
r.background_center_color IS NOT NULL,
COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0),
COALESCE(r.background_text_color, 0),
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text,
@ -280,8 +408,20 @@ JOIN documents d ON d.id=r.document_id
WHERE c.gift_id=$1`, giftID)
var entry domain.StarGiftCatalogEntry
var attrsJSON, thumbsJSON, sourceFormat string
var releasedByType string
var releasedByID int64
var hasBackground bool
var background domain.StarGiftBackground
if err := row.Scan(
&entry.Gift.ID, &entry.Gift.RevisionID, &entry.Gift.Stars, &entry.Gift.ConvertStars, &entry.Gift.Title,
&entry.Gift.Limited, &entry.Gift.SoldOut, &entry.Gift.Birthday, &entry.Gift.RequirePremium,
&entry.Gift.LimitedPerUser, &entry.Gift.PeerColorAvailable, &entry.Gift.Auction,
&entry.Gift.AvailabilityRemains, &entry.Gift.AvailabilityTotal, &entry.Gift.AvailabilityResale,
&entry.Gift.FirstSaleDate, &entry.Gift.LastSaleDate, &entry.Gift.ResellMinStars,
&releasedByType, &releasedByID, &entry.Gift.PerUserTotal, &entry.Gift.LockedUntilDate,
&entry.Gift.AuctionSlug, &entry.Gift.GiftsPerRound, &entry.Gift.AuctionStartDate,
&entry.Gift.UpgradeVariants, &hasBackground, &background.CenterColor, &background.EdgeColor,
&background.TextColor,
&entry.Gift.UpgradeStars, &entry.Gift.UpgradeTotal, &entry.Gift.UpgradeIssued,
&entry.Gift.Sticker.ID, &entry.Gift.Sticker.AccessHash, &entry.Gift.Sticker.FileReference, &entry.Gift.Sticker.Date,
&entry.Gift.Sticker.MimeType, &entry.Gift.Sticker.Size, &entry.Gift.Sticker.DCID, &attrsJSON, &thumbsJSON,
@ -291,6 +431,15 @@ WHERE c.gift_id=$1`, giftID)
); err != nil {
return domain.StarGiftCatalogEntry{}, err
}
if releasedByType != "" && releasedByID > 0 {
entry.Gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID}
}
if hasBackground {
entry.Gift.Background = &background
}
if entry.Gift.LimitedPerUser {
entry.Gift.PerUserRemains = entry.Gift.PerUserTotal
}
attrs, err := decodeDocumentAttributes(attrsJSON)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
@ -315,14 +464,14 @@ func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) (
WITH next_id AS (
SELECT nextval(pg_get_serial_sequence('public.peer_star_gifts', 'id'))::bigint AS id
)
INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, message)
INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, prepaid_upgrade_hash, gift_num, message)
SELECT next_id.id, $1,$2,$3,$4,$5,$6,
CASE WHEN $1 = 'channel' AND $7::bigint = 0 THEN next_id.id ELSE $7::bigint END,
$8,$9,$10,false,$11,$12,$13
$8,$9,$10,false,$11,$12,$13,$14,$15
FROM next_id
RETURNING id`,
string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.RevisionID, gift.MsgID, gift.SavedID, gift.Date,
gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.Message).Scan(&id)
gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.PrepaidUpgradeHash, gift.GiftNum, gift.Message).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create star gift: %w", err)
}
@ -347,7 +496,7 @@ func (s *StarGiftStore) ListByOwnerFiltered(ctx context.Context, filter domain.S
JOIN star_gift_catalog c ON c.gift_id = p.gift_id
LEFT JOIN star_gift_collectible_revisions acr
ON acr.id = c.collectible_revision_id AND acr.status = 'published'`
conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "NOT p.converted"}
conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "p.lifecycle_status = 'active'"}
args := []any{string(owner.Type), owner.ID}
if filter.ExcludeUnsaved {
conditions = append(conditions, "NOT p.unsaved")
@ -386,15 +535,35 @@ WHERE ci.saved_gift_id = p.id AND ci.collection_id = $%d
}
page := domain.SavedStarGiftPage{Count: total}
if cursor, ok := domain.DecodeStarGiftCursor(offset); ok {
args = append(args, cursor)
where += fmt.Sprintf(" AND p.id < $%d", len(args))
profileOrder := filter.CollectionID == 0
if cursor, ok := domain.DecodeSavedStarGiftListCursor(offset); ok {
if profileOrder && cursor.PinnedOrder > 0 {
args = append(args, cursor.PinnedOrder, cursor.ID)
where += fmt.Sprintf(` AND (
p.pinned_order = 0
OR p.pinned_order > $%d
OR (p.pinned_order = $%d AND p.id < $%d)
)`, len(args)-1, len(args)-1, len(args))
} else {
args = append(args, cursor.ID)
if profileOrder {
where += fmt.Sprintf(" AND p.pinned_order = 0 AND p.id < $%d", len(args))
} else {
where += fmt.Sprintf(" AND p.id < $%d", len(args))
}
}
}
orderBy := "ORDER BY p.id DESC"
if profileOrder {
orderBy = "ORDER BY (p.pinned_order = 0), p.pinned_order, p.id DESC"
}
args = append(args, limit+1)
limitPlaceholder := len(args)
rows, err := s.db.Query(ctx, `
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num,
p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at,
p.drop_original_details_stars, p.can_craft_at,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
@ -402,7 +571,7 @@ SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.ca
WHERE i.saved_gift_id=p.id), ARRAY[]::integer[])
FROM peer_star_gifts p `+joins+`
WHERE `+where+`
ORDER BY p.id DESC
`+orderBy+`
LIMIT $`+fmt.Sprint(limitPlaceholder), args...)
if err != nil {
return domain.SavedStarGiftPage{}, fmt.Errorf("list star gifts: %w", err)
@ -421,7 +590,12 @@ LIMIT $`+fmt.Sprint(limitPlaceholder), args...)
}
if len(gifts) > limit {
gifts = gifts[:limit]
page.NextOffset = domain.EncodeStarGiftCursor(gifts[len(gifts)-1].ID)
last := gifts[len(gifts)-1]
pinnedOrder := 0
if profileOrder {
pinnedOrder = last.PinnedOrder
}
page.NextOffset = domain.EncodeSavedStarGiftListCursor(pinnedOrder, last.ID)
}
page.Gifts = gifts
return page, nil
@ -434,47 +608,95 @@ func (s *StarGiftStore) ResolveSavedIDs(ctx context.Context, owner domain.Peer,
if len(refs) == 0 {
return []int64{}, nil
}
type resolveKey struct {
value int64
slug string
}
keys := make([]resolveKey, 0, len(refs))
values := make([]int64, 0, len(refs))
seenValues := make(map[int64]struct{}, len(refs))
column := "msg_id"
slugs := make([]string, 0, len(refs))
seenKeys := make(map[string]struct{}, len(refs))
for _, ref := range refs {
if ref.Owner != owner || !ref.Valid() {
return nil, domain.ErrStarGiftNotFound
}
if ref.Slug != "" {
slug := strings.ToLower(strings.TrimSpace(ref.Slug))
key := "slug:" + slug
if _, duplicate := seenKeys[key]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seenKeys[key] = struct{}{}
keys = append(keys, resolveKey{slug: slug})
slugs = append(slugs, slug)
continue
}
value := int64(ref.MsgID)
if owner.Type == domain.PeerTypeChannel {
column = "saved_id"
value = ref.SavedID
}
if _, duplicate := seenValues[value]; duplicate {
key := fmt.Sprintf("id:%d", value)
if _, duplicate := seenKeys[key]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seenValues[value] = struct{}{}
seenKeys[key] = struct{}{}
keys = append(keys, resolveKey{value: value})
values = append(values, value)
}
rows, err := s.db.Query(ctx, `SELECT `+column+`::bigint, id FROM peer_star_gifts
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND `+column+`::bigint=ANY($3::bigint[])`, string(owner.Type), owner.ID, values)
query := `SELECT p.saved_id::bigint, COALESCE(u.slug, ''), p.id
FROM peer_star_gifts p
LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id
WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active'
AND (p.saved_id::bigint=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))`
if owner.Type == domain.PeerTypeUser {
query = `SELECT p.msg_id::bigint, COALESCE(u.slug, ''), p.id
FROM peer_star_gifts p
LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id
WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active'
AND (p.msg_id::bigint=ANY($3::bigint[])
OR u.slug=ANY($4::text[]))`
}
rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs)
if err != nil {
return nil, fmt.Errorf("resolve saved star gifts: %w", err)
}
defer rows.Close()
resolved := make(map[int64]int64, len(values))
resolvedValues := make(map[int64]int64, len(values))
resolvedSlugs := make(map[string]int64, len(slugs))
for rows.Next() {
var value, id int64
if err := rows.Scan(&value, &id); err != nil {
var primaryValue, id int64
var slug string
if err := rows.Scan(&primaryValue, &slug, &id); err != nil {
return nil, fmt.Errorf("scan resolved saved star gift: %w", err)
}
resolved[value] = id
if existing := resolvedValues[primaryValue]; existing != 0 && existing != id {
return nil, domain.ErrStarGiftCollectibleInvalid
}
resolvedValues[primaryValue] = id
if slug != "" {
if existing := resolvedSlugs[slug]; existing != 0 && existing != id {
return nil, domain.ErrStarGiftCollectibleInvalid
}
resolvedSlugs[slug] = id
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate resolved saved star gifts: %w", err)
}
out := make([]int64, 0, len(values))
for _, value := range values {
id := resolved[value]
out := make([]int64, 0, len(keys))
seenIDs := make(map[int64]struct{}, len(keys))
for _, key := range keys {
id := resolvedValues[key.value]
if key.slug != "" {
id = resolvedSlugs[key.slug]
}
if id == 0 {
return nil, domain.ErrStarGiftNotFound
}
if _, duplicate := seenIDs[id]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seenIDs[id] = struct{}{}
out = append(out, id)
}
return out, nil
@ -487,7 +709,9 @@ func (s *StarGiftStore) GetByRef(ctx context.Context, ref domain.SavedStarGiftRe
where, args := savedStarGiftRefWhere(ref)
row := s.db.QueryRow(ctx, `
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num,
p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at,
p.drop_original_details_stars, p.can_craft_at,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
@ -510,7 +734,7 @@ func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (in
return 0, nil
}
var n int
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND NOT converted AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil {
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND lifecycle_status='active' AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil {
return 0, fmt.Errorf("count star gifts: %w", err)
}
return n, nil
@ -524,7 +748,7 @@ func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGift
args = append(args, unsaved)
tag, err := s.db.Exec(ctx, `
UPDATE peer_star_gifts SET unsaved = $4
WHERE `+where+` AND NOT converted`, args...)
WHERE `+where+` AND lifecycle_status='active'`, args...)
if err != nil {
return false, fmt.Errorf("set star gift unsaved: %w", err)
}
@ -543,7 +767,9 @@ func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarG
where, args := savedStarGiftRefWhere(ref)
row := tx.QueryRow(ctx, `
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num,
p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at,
p.drop_original_details_stars, p.can_craft_at,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
@ -564,13 +790,14 @@ WHERE `+where+` FOR UPDATE`, args...)
if g.UniqueGiftID != 0 {
return domain.ErrStarGiftAlreadyUpgraded
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, lifecycle_status='converted', unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil {
return fmt.Errorf("mark star gift converted: %w", err)
}
if err := removeSavedGiftFromCollections(ctx, tx, g.Owner, g.ID); err != nil {
return err
}
g.Converted = true
g.LifecycleStatus = domain.StarGiftLifecycleConverted
g.Unsaved = true
g.PinnedOrder = 0
g.CollectionIDs = nil
@ -587,7 +814,9 @@ func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) {
var g domain.SavedStarGift
var ownerType string
if err := row.Scan(&g.ID, &ownerType, &g.Owner.ID, &g.FromUserID, &g.GiftID, &g.RevisionID, &g.MsgID, &g.SavedID, &g.Date,
&g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.Message, &g.UniqueGiftID,
&g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.PrepaidUpgradeHash, &g.GiftNum,
&g.LifecycleStatus, &g.TransferStars, &g.CanExportAt, &g.CanTransferAt, &g.CanResellAt,
&g.DropOriginalDetailsStars, &g.CanCraftAt, &g.Message, &g.UniqueGiftID,
&g.UpgradeMsgID, &g.PinnedOrder, &g.CollectionIDs); err != nil {
return domain.SavedStarGift{}, err
}
@ -597,6 +826,10 @@ func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) {
func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) {
args := []any{string(ref.Owner.Type), ref.Owner.ID}
if ref.Slug != "" {
args = append(args, strings.ToLower(strings.TrimSpace(ref.Slug)))
return "owner_peer_type = $1 AND owner_peer_id = $2 AND unique_gift_id = (SELECT id FROM unique_star_gifts WHERE slug = $3)", args
}
switch ref.Owner.Type {
case domain.PeerTypeChannel:
args = append(args, ref.SavedID)

View file

@ -14,6 +14,34 @@ import (
"telesrv/internal/store/postgres/sqlcgen"
)
func nullablePermille(attribute domain.StarGiftCollectibleAttribute) any {
if attribute.RarityKind != domain.StarGiftRarityPermille {
return nil
}
return attribute.RarityPermille
}
func nullableSHA256(value []byte) any {
if len(value) == 0 {
return nil
}
return value
}
func nullablePositiveInt64(value int64) any {
if value <= 0 {
return nil
}
return value
}
func nullableOfficialGiftJSON(value []byte) any {
if len(value) == 0 {
return nil
}
return string(value)
}
func (s *StarGiftStore) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
write.Actor = strings.TrimSpace(write.Actor)
@ -38,13 +66,15 @@ SELECT COALESCE(MAX(revision), 0) + 1 FROM star_gift_collectible_revisions WHERE
var revisionID int64
if err := tx.QueryRow(ctx, `
INSERT INTO star_gift_collectible_revisions
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id)
VALUES ($1,$2,$3,$4,$5,'draft',$6,$7)
RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID).Scan(&revisionID); err != nil {
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id,
official_gift_id, source_manifest_sha256)
VALUES ($1,$2,$3,$4,$5,'draft',$6,$7,NULLIF($8::bigint,0),$9)
RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID,
write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256)).Scan(&revisionID); err != nil {
return fmt.Errorf("insert collectible revision: %w", err)
}
media := NewMediaStore(tx)
insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute) error {
insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute, models bool) error {
for _, attribute := range attributes {
if err := media.PutDocument(ctx, *attribute.Document); err != nil {
return fmt.Errorf("put collectible %s document: %w", attribute.Kind, err)
@ -53,35 +83,53 @@ RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, wr
return fmt.Errorf("put collectible %s blob: %w", attribute.Kind, err)
}
animation := attribute.Animation
query := fmt.Sprintf(`
var query string
if models {
query = fmt.Sprintf(`
INSERT INTO %s
(collectible_revision_id, name, document_id, animation_json, animation_sha256,
source_name, source_format, width, height, frame_rate, in_point, out_point,
rarity_permille, sort_order)
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, table)
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
attribute.RarityPermille, attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
rarity_kind, rarity_permille, crafted, official_document_id, sort_order)
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, table)
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
string(attribute.RarityKind), nullablePermille(attribute), attribute.Crafted,
nullablePositiveInt64(attribute.OfficialDocumentID), attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
}
} else {
query = fmt.Sprintf(`
INSERT INTO %s
(collectible_revision_id, name, document_id, animation_json, animation_sha256,
source_name, source_format, width, height, frame_rate, in_point, out_point,
rarity_kind, rarity_permille, official_document_id, sort_order)
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, table)
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
string(attribute.RarityKind), nullablePermille(attribute), nullablePositiveInt64(attribute.OfficialDocumentID),
attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
}
}
}
return nil
}
if err := insertAnimated("star_gift_collectible_models", write.Models); err != nil {
if err := insertAnimated("star_gift_collectible_models", write.Models, true); err != nil {
return err
}
if err := insertAnimated("star_gift_collectible_patterns", write.Patterns); err != nil {
if err := insertAnimated("star_gift_collectible_patterns", write.Patterns, false); err != nil {
return err
}
for _, attribute := range write.Backdrops {
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_collectible_backdrops
(collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_permille, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID,
text_color, rarity_kind, rarity_permille, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID,
attribute.CenterColor, attribute.EdgeColor, attribute.PatternColor, attribute.TextColor,
attribute.RarityPermille, attribute.SortOrder); err != nil {
string(attribute.RarityKind), nullablePermille(attribute), attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible backdrop: %w", err)
}
}
@ -152,10 +200,11 @@ func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID in
var publishedAt pgtype.Timestamptz
if err := db.QueryRow(ctx, `
SELECT id, gift_id, revision, upgrade_stars, supply_total, issued, slug_prefix, status,
created_by, created_at, published_at
created_by, created_at, published_at, COALESCE(official_gift_id,0), source_manifest_sha256
FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan(
&revision.ID, &revision.GiftID, &revision.Revision, &revision.UpgradeStars, &revision.SupplyTotal,
&revision.Issued, &revision.SlugPrefix, &status, &revision.CreatedBy, &revision.CreatedAt, &publishedAt,
&revision.OfficialGiftID, &revision.SourceManifestSHA256,
); err != nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("get collectible revision: %w", err)
}
@ -184,13 +233,20 @@ func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, rev
return nil, domain.ErrStarGiftCollectibleInvalid
}
rows, err := db.Query(ctx, fmt.Sprintf(`
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_permille, a.sort_order,
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_kind, COALESCE(a.rarity_permille,0),
%s, COALESCE(a.official_document_id,0), a.sort_order,
a.animation_json::text, a.animation_sha256, a.source_name, a.source_format,
a.width, a.height, a.frame_rate, a.in_point, a.out_point,
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
FROM %s a JOIN documents d ON d.id=a.document_id
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisionID)
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`,
func() string {
if kind == domain.StarGiftCollectibleModel {
return "a.crafted"
}
return "false"
}(), table), revisionID)
if err != nil {
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err)
}
@ -199,7 +255,8 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisio
for rows.Next() {
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Document: &domain.Document{}, Animation: &domain.StarGiftAnimation{}}
var attrsJSON, thumbsJSON, sourceFormat string
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityPermille, &attribute.SortOrder,
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityKind,
&attribute.RarityPermille, &attribute.Crafted, &attribute.OfficialDocumentID, &attribute.SortOrder,
&attribute.Animation.JSON, &attribute.Animation.SHA256, &attribute.Animation.SourceName, &sourceFormat,
&attribute.Animation.Width, &attribute.Animation.Height, &attribute.Animation.FrameRate, &attribute.Animation.InPoint, &attribute.Animation.OutPoint,
&attribute.Document.ID, &attribute.Document.AccessHash, &attribute.Document.FileReference, &attribute.Document.Date,
@ -221,7 +278,7 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisio
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) {
rows, err := db.Query(ctx, `
SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_permille, sort_order
text_color, rarity_kind, COALESCE(rarity_permille,0), sort_order
FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID)
if err != nil {
return nil, fmt.Errorf("list collectible backdrops: %w", err)
@ -232,7 +289,7 @@ FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY s
attribute := domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop}
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.BackdropID,
&attribute.CenterColor, &attribute.EdgeColor, &attribute.PatternColor, &attribute.TextColor,
&attribute.RarityPermille, &attribute.SortOrder); err != nil {
&attribute.RarityKind, &attribute.RarityPermille, &attribute.SortOrder); err != nil {
return nil, err
}
out = append(out, attribute)
@ -292,6 +349,37 @@ func (s *StarGiftStore) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64)
return out, nil
}
func (s *StarGiftStore) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
if owner.ID <= 0 || limit <= 0 {
return []domain.UniqueStarGift{}, nil
}
if limit > domain.MaxSavedStarGiftsLimit {
limit = domain.MaxSavedStarGiftsLimit
}
rows, err := s.db.Query(ctx, uniqueStarGiftQuery(`
u.owner_peer_type=$1 AND u.owner_peer_id=$2
AND NOT u.burned AND u.owner_address=''
AND sg.lifecycle_status='active'`)+`
ORDER BY u.id DESC
LIMIT $3`, string(owner.Type), owner.ID, limit)
if err != nil {
return nil, fmt.Errorf("list unique star gifts by owner: %w", err)
}
defer rows.Close()
out := make([]domain.UniqueStarGift, 0, limit)
for rows.Next() {
gift, err := scanUniqueStarGift(rows)
if err != nil {
return nil, err
}
out = append(out, gift)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate unique star gifts by owner: %w", err)
}
return out, nil
}
func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string, value any) (domain.UniqueStarGift, bool, error) {
row := s.db.QueryRow(ctx, uniqueStarGiftQuery(predicate), value)
unique, err := scanUniqueStarGift(row)
@ -307,14 +395,24 @@ func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string,
func uniqueStarGiftQuery(predicate string) string {
return fmt.Sprintf(`
SELECT u.id, u.gift_id, u.collectible_revision_id, u.source_saved_gift_id, u.title, u.slug, u.num,
u.owner_peer_type, u.owner_peer_id, u.keep_original_details, u.created_at,
r.issued, r.supply_total, sg.from_user_id, sg.owner_peer_type, sg.owner_peer_id,
COALESCE(u.owner_peer_type,''), COALESCE(u.owner_peer_id,0), u.keep_original_details, u.created_at,
u.require_premium, u.resale_ton_only, u.theme_available, u.burned, u.crafted,
u.owner_name, u.owner_address, u.gift_address,
COALESCE(l.currency,''), COALESCE(l.amount,0), COALESCE(l.version,0),
COALESCE(u.released_by_peer_type,''), COALESCE(u.released_by_peer_id,0),
u.value_amount, u.value_currency, u.value_usd_amount,
COALESCE(u.theme_peer_type,''), COALESCE(u.theme_peer_id,0),
COALESCE(u.host_peer_type,''), COALESCE(u.host_peer_id,0),
u.offer_min_stars, u.craft_chance_permille, u.last_sale_date,
u.last_sale_currency, u.last_sale_amount,
r.issued, r.supply_total, sg.from_user_id, u.original_owner_peer_type, u.original_owner_peer_id,
sg.gift_date, sg.message, sg.name_hidden,
m.id, m.name, m.rarity_permille, md.id, md.access_hash, md.file_reference, md.date,
m.id, m.name, m.rarity_kind, COALESCE(m.rarity_permille,0), m.crafted, md.id, md.access_hash, md.file_reference, md.date,
md.mime_type, md.size, md.dc_id, md.attributes::text, md.thumbs::text,
p.id, p.name, p.rarity_permille, pd.id, pd.access_hash, pd.file_reference, pd.date,
p.id, p.name, p.rarity_kind, COALESCE(p.rarity_permille,0), pd.id, pd.access_hash, pd.file_reference, pd.date,
pd.mime_type, pd.size, pd.dc_id, pd.attributes::text, pd.thumbs::text,
b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color, b.rarity_permille
b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color,
b.rarity_kind, COALESCE(b.rarity_permille,0)
FROM unique_star_gifts u
JOIN star_gift_collectible_revisions r ON r.id=u.collectible_revision_id
JOIN star_gift_collectible_models m ON m.id=u.model_attribute_id
@ -323,12 +421,14 @@ JOIN star_gift_collectible_patterns p ON p.id=u.pattern_attribute_id
JOIN documents pd ON pd.id=p.document_id
JOIN star_gift_collectible_backdrops b ON b.id=u.backdrop_attribute_id
JOIN peer_star_gifts sg ON sg.id=u.source_saved_gift_id
LEFT JOIN star_gift_listings l ON l.unique_gift_id=u.id
WHERE %s`, predicate)
}
func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) {
var unique domain.UniqueStarGift
var ownerType, originalOwnerType string
var ownerType, originalOwnerType, listingCurrency, releasedByType, themePeerType, hostPeerType, lastSaleCurrency string
var listingAmount, lastSaleAmount int64
unique.Model.Kind = domain.StarGiftCollectibleModel
unique.Pattern.Kind = domain.StarGiftCollectiblePattern
unique.Backdrop.Kind = domain.StarGiftCollectibleBackdrop
@ -337,23 +437,40 @@ func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) {
var modelAttrs, modelThumbs, patternAttrs, patternThumbs string
if err := row.Scan(&unique.ID, &unique.GiftID, &unique.CollectibleRevisionID, &unique.SourceSavedGiftID,
&unique.Title, &unique.Slug, &unique.Num, &ownerType, &unique.Owner.ID, &unique.KeepOriginalDetails,
&unique.CreatedAt, &unique.AvailabilityIssued, &unique.AvailabilityTotal,
&unique.CreatedAt, &unique.RequirePremium, &unique.ResaleTonOnly, &unique.ThemeAvailable,
&unique.Burned, &unique.Crafted, &unique.OwnerName, &unique.OwnerAddress, &unique.GiftAddress,
&listingCurrency, &listingAmount, &unique.ResellVersion, &releasedByType, &unique.ReleasedBy.ID,
&unique.ValueAmount, &unique.ValueCurrency, &unique.ValueUSD,
&themePeerType, &unique.ThemePeer.ID, &hostPeerType, &unique.Host.ID,
&unique.OfferMinStars, &unique.CraftChancePermille, &unique.LastSaleDate,
&lastSaleCurrency, &lastSaleAmount,
&unique.AvailabilityIssued, &unique.AvailabilityTotal,
&unique.OriginalFromUserID, &originalOwnerType, &unique.OriginalOwner.ID, &unique.OriginalDate,
&unique.OriginalMessage, &unique.OriginalNameHidden,
&unique.Model.ID, &unique.Model.Name, &unique.Model.RarityPermille,
&unique.Model.ID, &unique.Model.Name, &unique.Model.RarityKind, &unique.Model.RarityPermille, &unique.Model.Crafted,
&unique.Model.Document.ID, &unique.Model.Document.AccessHash, &unique.Model.Document.FileReference,
&unique.Model.Document.Date, &unique.Model.Document.MimeType, &unique.Model.Document.Size,
&unique.Model.Document.DCID, &modelAttrs, &modelThumbs,
&unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityPermille,
&unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityKind, &unique.Pattern.RarityPermille,
&unique.Pattern.Document.ID, &unique.Pattern.Document.AccessHash, &unique.Pattern.Document.FileReference,
&unique.Pattern.Document.Date, &unique.Pattern.Document.MimeType, &unique.Pattern.Document.Size,
&unique.Pattern.Document.DCID, &patternAttrs, &patternThumbs,
&unique.Backdrop.ID, &unique.Backdrop.Name, &unique.Backdrop.BackdropID, &unique.Backdrop.CenterColor,
&unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor, &unique.Backdrop.RarityPermille); err != nil {
&unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor,
&unique.Backdrop.RarityKind, &unique.Backdrop.RarityPermille); err != nil {
return domain.UniqueStarGift{}, fmt.Errorf("get unique star gift: %w", err)
}
unique.Owner.Type = domain.PeerType(ownerType)
unique.OriginalOwner.Type = domain.PeerType(originalOwnerType)
unique.ReleasedBy.Type = domain.PeerType(releasedByType)
unique.ThemePeer.Type = domain.PeerType(themePeerType)
unique.Host.Type = domain.PeerType(hostPeerType)
if listingCurrency != "" && listingAmount > 0 {
unique.ResellAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(listingCurrency), Amount: listingAmount}
}
if lastSaleCurrency != "" && unique.LastSaleDate > 0 {
unique.LastSaleAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(lastSaleCurrency), Amount: lastSaleAmount}
}
unique.Model.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Pattern.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Backdrop.CollectibleRevisionID = unique.CollectibleRevisionID
@ -603,7 +720,7 @@ func validatePostgresCollectionGiftIDs(ctx context.Context, db sqlcgen.DBTX, own
}
rows, err := db.Query(ctx, `
SELECT id FROM peer_star_gifts
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND id=ANY($3::bigint[])
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND lifecycle_status='active' AND id=ANY($3::bigint[])
FOR UPDATE`, string(owner.Type), owner.ID, ids)
if err != nil {
return nil, err

View file

@ -34,26 +34,35 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
poolRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "comet-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleModel, Name: "Aurora", RarityPermille: 1000,
Kind: domain.StarGiftCollectibleModel, Name: "Aurora", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 922,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs"),
OfficialDocumentID: 5100000000000000001,
}, {
Kind: domain.StarGiftCollectibleModel, Name: "Crafted Aurora", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
Document: collectibleTestDocumentPtr(baseDocumentID+3, "crafted-model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+3, "crafted-model"), Animation: collectibleTestAnimationPtr("crafted-model.tgs"),
OfficialDocumentID: 5100000000000000003,
}},
Patterns: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+2, "pattern.tgs"),
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,
RarityPermille: 1000,
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 999,
}},
Actor: "integration", CommandID: "collectibles-" + suffix,
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: make([]byte, 32),
})
if err != nil {
t.Fatalf("publish collectible pool: %v", err)
}
if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 1 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 {
if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 2 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 ||
!poolRevision.Models[1].Crafted || poolRevision.Models[1].RarityKind != domain.StarGiftRarityLegendary ||
poolRevision.Models[1].RarityPermille != 0 || poolRevision.Models[0].OfficialDocumentID != 5100000000000000001 {
t.Fatalf("published pool = %+v", poolRevision)
}
availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1})
@ -74,21 +83,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
t.Fatalf("issued after rejected manual update = %d err %v, want 0", guardedIssued, err)
}
savedID, err := gifts.Create(ctx, domain.SavedStarGift{
messages := NewMessageStore(pool)
saved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
MsgID: 700001, Date: 1700001000, ConvertStars: 25, Message: "original",
Date: 1700001000, ConvertStars: 25, Message: "original",
})
if err != nil {
t.Fatalf("create saved gift: %v", err)
}
savedID := saved.ID
stars := NewStarsStore(pool)
if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, 1700001001); err != nil {
t.Fatalf("grant upgrade stars: %v", err)
}
messages := NewMessageStore(pool)
upgrades := NewStarGiftUpgradeStore(pool, messages)
req := domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700001},
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: saved.MsgID},
KeepOriginalDetails: true, ChargeStars: 100, FormID: 991,
CommandKey: "paid-" + suffix, Date: 1700001002,
}
@ -108,6 +115,31 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
ownerMessage.Media.ServiceAction.StarGiftUnique == nil || ownerMessage.Media.ServiceAction.StarGiftUnique.Gift.ID != upgraded.Unique.ID {
t.Fatalf("owner upgrade service message = %+v", ownerMessage)
}
uniqueAction := ownerMessage.Media.ServiceAction.StarGiftUnique
if uniqueAction.SavedID != int64(saved.MsgID) {
t.Fatalf("unique action saved_id = %d, want stable source msg id %d", uniqueAction.SavedID, saved.MsgID)
}
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil ||
ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != ownerMessage.ID ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.CanUpgrade {
t.Fatalf("owner source gift was not durably marked upgraded: %+v", ownerSourceEdit)
}
senderSourceEdit := upgradedSourceEditForUser(upgraded, sender.ID)
if senderSourceEdit.Message.Media == nil || senderSourceEdit.Message.Media.ServiceAction == nil ||
senderSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
senderSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Send.SenderMessage.ID {
t.Fatalf("sender source gift has wrong box-local upgrade link: %+v", senderSourceEdit)
}
difference, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, ownerMessage.Pts-1, 4)
if err != nil || len(difference) < 2 || difference[0].Type != domain.UpdateEventNewMessage ||
difference[0].Message.ID != ownerMessage.ID || difference[1].Type != domain.UpdateEventEditMessage ||
difference[1].Message.ID != saved.MsgID || difference[1].Message.Media == nil ||
difference[1].Message.Media.ServiceAction == nil || difference[1].Message.Media.ServiceAction.StarGift == nil ||
difference[1].Message.Media.ServiceAction.StarGift.UpgradeMsgID != ownerMessage.ID {
t.Fatalf("owner upgrade difference = %+v err %v", difference, err)
}
var (
issued, uniqueCount, commandCount int
@ -128,12 +160,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
if issued != 1 || uniqueCount != 1 || commandCount != 1 || reason != string(domain.StarsReasonGiftUpgrade) {
t.Fatalf("durable aggregate issued=%d unique=%d command=%d reason=%q", issued, uniqueCount, commandCount, reason)
}
receipt, found, err := upgrades.StarGiftUpgradeReceipt(ctx, owner.ID, req.CommandKey)
if err != nil || !found || receipt.SourceSavedGiftID != savedID || receipt.UniqueGiftID != upgraded.Unique.ID ||
receipt.FormID != req.FormID || receipt.ChargeStars != req.ChargeStars || receipt.RequirePrepaid ||
!receipt.KeepOriginalDetails || receipt.BalanceAfter != 900 || receipt.SourceEditPts != ownerSourceEdit.Event.Pts {
t.Fatalf("upgrade receipt = %+v found=%v err=%v", receipt, found, err)
}
replayed, err := upgrades.UpgradeStarGift(ctx, req)
if err != nil {
t.Fatalf("replay upgrade: %v", err)
}
if !replayed.Duplicate || replayed.Unique.ID != upgraded.Unique.ID || replayed.Balance.Balance != 900 {
if !replayed.Duplicate || replayed.Unique.ID != upgraded.Unique.ID || replayed.Balance.Balance != 900 ||
upgradedSourceEditForUser(replayed, owner.ID).Event.Pts != ownerSourceEdit.Event.Pts {
t.Fatalf("replayed upgrade = %+v", replayed)
}
conflictingReplay := req
@ -152,17 +191,15 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
t.Fatalf("balance after retries = %+v err %v", bal, err)
}
prepaidSavedID, err := gifts.Create(ctx, domain.SavedStarGift{
prepaidSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
// A later pool revision may raise the current price; the historical paid
// amount remains an entitlement instead of being compared to that price.
MsgID: 700002, Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50,
Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50,
})
if err != nil {
t.Fatalf("create prepaid saved gift: %v", err)
}
prepaidSavedID := prepaidSaved.ID
prepaid, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700002},
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: prepaidSaved.MsgID},
RequirePrepaid: true, CommandKey: "prepaid-" + suffix, Date: 1700001005,
})
if err != nil {
@ -174,26 +211,24 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
t.Fatalf("prepaid upgrade = %+v", prepaid)
}
insufficientSavedID, err := gifts.Create(ctx, domain.SavedStarGift{
insufficientSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
MsgID: 700003, Date: 1700001006, ConvertStars: 25,
Date: 1700001006, ConvertStars: 25,
})
if err != nil {
t.Fatalf("create insufficient saved gift: %v", err)
}
insufficientSavedID := insufficientSaved.ID
if _, err := stars.Debit(ctx, owner.ID, 850, domain.StarsReasonReaction,
domain.Peer{Type: domain.PeerTypeChannel, ID: 777001}, 1700001007, "paid reaction", ""); err != nil {
t.Fatalf("seed isolated paid reaction debit: %v", err)
}
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003},
ChargeStars: 100, CommandKey: "insufficient-" + suffix, Date: 1700001008,
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID},
ChargeStars: 100, FormID: 994, CommandKey: "insufficient-" + suffix, Date: 1700001008,
}); !errors.Is(err, domain.ErrStarsInsufficient) {
t.Fatalf("insufficient upgrade err = %v", err)
}
insufficientSaved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003})
if err != nil || !found || insufficientSaved.ID != insufficientSavedID || insufficientSaved.UniqueGiftID != 0 {
t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientSaved, found, err)
insufficientAfter, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID})
if err != nil || !found || insufficientAfter.ID != insufficientSavedID || insufficientAfter.UniqueGiftID != 0 {
t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientAfter, found, err)
}
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&issued); err != nil || issued != 2 {
t.Fatalf("issued after rejected upgrade = %d err %v, want 2", issued, err)
@ -220,12 +255,10 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
concurrentOwner := createTestUser(t, ctx, users, "+1778"+suffix+"43", "ConcurrentOwner", "")
concurrentPeer := domain.Peer{Type: domain.PeerTypeUser, ID: concurrentOwner.ID}
if _, err := gifts.Create(ctx, domain.SavedStarGift{
concurrentSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: concurrentPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
MsgID: 700004, Date: 1700001010, ConvertStars: 25,
}); err != nil {
t.Fatalf("create concurrent upgrade target: %v", err)
}
Date: 1700001010, ConvertStars: 25,
})
if _, _, err := stars.EnsureGrant(ctx, concurrentOwner.ID, 150, 1700001011); err != nil {
t.Fatalf("grant concurrent balance: %v", err)
}
@ -238,7 +271,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
go func() {
<-start
_, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: 700004},
UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: concurrentSaved.MsgID},
ChargeStars: 100, FormID: 993, CommandKey: "concurrent-upgrade-" + suffix, Date: 1700001012,
})
results <- concurrentDebitResult{kind: "gift_upgrade", err: err}
@ -302,19 +335,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
soldOutRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: soldOutEntry.Gift.ID, UpgradeStars: 10, SupplyTotal: 1, SlugPrefix: "nova-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityPermille: 1000,
Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs"),
}},
Patterns: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"),
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,
RarityPermille: 1000,
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
}},
Actor: "integration", CommandID: "soldout-pool-" + suffix,
})
@ -323,27 +356,26 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
}
soldOutOwner := createTestUser(t, ctx, users, "+1778"+suffix+"44", "SoldOutOwner", "")
soldOutPeer := domain.Peer{Type: domain.PeerTypeUser, ID: soldOutOwner.ID}
for index, msgID := range []int{700010, 700011} {
if _, err := gifts.Create(ctx, domain.SavedStarGift{
soldOutSaved := make([]domain.SavedStarGift, 0, 2)
for index := range 2 {
soldOutSaved = append(soldOutSaved, createCollectibleSavedGift(t, ctx, messages, gifts, soldOutEntry.Gift, domain.SavedStarGift{
Owner: soldOutPeer, FromUserID: sender.ID, GiftID: soldOutEntry.Gift.ID, RevisionID: soldOutEntry.Gift.RevisionID,
MsgID: msgID, Date: 1700001020 + index, ConvertStars: 10,
}); err != nil {
t.Fatalf("create sold-out target %d: %v", msgID, err)
}
Date: 1700001020 + index, ConvertStars: 10,
}))
}
if _, _, err := stars.EnsureGrant(ctx, soldOutOwner.ID, 100, 1700001022); err != nil {
t.Fatalf("grant sold-out owner balance: %v", err)
}
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700010},
ChargeStars: 10, CommandKey: "soldout-first-" + suffix, Date: 1700001023,
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[0].MsgID},
ChargeStars: 10, FormID: 995, CommandKey: "soldout-first-" + suffix, Date: 1700001023,
}); err != nil {
t.Fatalf("fill collectible supply: %v", err)
}
balanceBeforeSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID)
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700011},
ChargeStars: 10, CommandKey: "soldout-second-" + suffix, Date: 1700001024,
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[1].MsgID},
ChargeStars: 10, FormID: 996, CommandKey: "soldout-second-" + suffix, Date: 1700001024,
}); !errors.Is(err, domain.ErrStarGiftCollectibleSoldOut) {
t.Fatalf("sold-out upgrade err = %v", err)
}
@ -357,7 +389,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
if err != nil {
t.Fatalf("create ordinary collection: %v", err)
}
converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003})
converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID})
if err != nil || !converted.Converted || converted.PinnedOrder != 0 || len(converted.CollectionIDs) != 0 {
t.Fatalf("convert collection member = %+v err %v", converted, err)
}
@ -386,6 +418,106 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
}
}
func TestStarGiftUpgradeWithoutCraftedModelDoesNotAdvertiseCraft(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1779"+suffix+"51", "NoCraftSender", "")
owner := createTestUser(t, ctx, users, "+1779"+suffix+"52", "NoCraftOwner", "")
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "No Craft " + suffix, Stars: 50, ConvertStars: 25, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "no-craft-gift.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "no-craft-gift"), Animation: collectibleTestAnimation("no-craft-gift.tgs"),
Actor: "integration", CommandID: "no-craft-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create no-craft catalog gift: %v", err)
}
revision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "no-craft-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleModel, Name: "Ordinary", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "no-craft-model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+1, "no-craft-model"), Animation: collectibleTestAnimationPtr("no-craft-model.tgs"),
}},
Patterns: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: 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,
}},
Actor: "integration", CommandID: "no-craft-pool-" + suffix,
})
if err != nil {
t.Fatalf("publish no-craft pool: %v", err)
}
if len(revision.Models) != 1 || revision.Models[0].Crafted {
t.Fatalf("no-craft pool models = %+v", revision.Models)
}
messages := NewMessageStore(pool)
saved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
Date: now, ConvertStars: 25,
})
stars := NewStarsStore(pool)
if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, now); err != nil {
t.Fatalf("grant no-craft upgrade stars: %v", err)
}
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 750,
}))
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: saved.MsgID},
ChargeStars: 100, FormID: 551, CommandKey: "no-craft-upgrade-" + suffix, Date: now + 1,
})
if err != nil {
t.Fatalf("upgrade no-craft gift: %v", err)
}
uniqueAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if upgraded.Unique.CraftChancePermille != 0 || upgraded.Saved.CanCraftAt != 0 ||
uniqueAction == nil || uniqueAction.Gift.CraftChancePermille != 0 || uniqueAction.CanCraftAt != 0 {
t.Fatalf("no-craft capability leaked: saved=%+v unique=%+v action=%+v", upgraded.Saved, upgraded.Unique, uniqueAction)
}
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000)
page, err := lifecycle.ListCraftStarGifts(ctx, owner.ID, entry.Gift.ID, "", 10)
if err != nil || page.Count != 0 || len(page.Gifts) != 0 {
t.Fatalf("no-craft candidate page = %+v err %v", page, err)
}
if _, err := lifecycle.CraftStarGift(ctx, domain.StarGiftCraftRequest{
UserID: owner.ID, Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: saved.MsgID}},
CommandKey: "no-craft-attempt-" + suffix, Date: now + 2,
}); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
t.Fatalf("no-craft attempt err = %v", err)
}
var lifecycleStatus string
var burned bool
var commandCount int
if err := pool.QueryRow(ctx, `SELECT p.lifecycle_status,u.burned
FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE p.id=$1`, upgraded.Saved.ID).
Scan(&lifecycleStatus, &burned); err != nil {
t.Fatalf("load no-craft aggregate: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`,
owner.ID, "no-craft-attempt-"+suffix).Scan(&commandCount); err != nil {
t.Fatalf("count no-craft commands: %v", err)
}
if lifecycleStatus != "active" || burned || commandCount != 0 {
t.Fatalf("no-craft attempt mutated aggregate: status=%q burned=%t commands=%d", lifecycleStatus, burned, commandCount)
}
}
func collectibleTestAnimation(name string) domain.StarGiftAnimation {
return domain.StarGiftAnimation{
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,
@ -416,6 +548,13 @@ func collectibleTestDocumentPtr(id int64, name string) *domain.Document {
return &document
}
func collectibleTestPatternDocumentPtr(id int64, name string) *domain.Document {
document := collectibleTestDocument(id, name)
document.Attributes[1] = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: "🎁", TextColor: true}
document.Thumbs = []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}}
return &document
}
func collectibleTestBlob(id int64, suffix string) domain.FileBlob {
return domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", id), Backend: domain.MediaBackendLocalFS,
@ -427,3 +566,53 @@ func collectibleTestBlobPtr(id int64, suffix string) *domain.FileBlob {
blob := collectibleTestBlob(id, suffix)
return &blob
}
// createCollectibleSavedGift seeds the same valid source-message + saved-gift
// invariant as the purchase aggregate. Tests must not invent a peer_star_gifts
// msg_id that has no durable message box behind it.
func createCollectibleSavedGift(
t *testing.T,
ctx context.Context,
messages *MessageStore,
gifts *StarGiftStore,
gift domain.StarGift,
saved domain.SavedStarGift,
) domain.SavedStarGift {
t.Helper()
sticker := gift.Sticker
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: saved.FromUserID,
RecipientUserID: saved.Owner.ID,
RandomID: (time.Now().UnixNano() & 0x7fffffffffffffff) ^ saved.Owner.ID ^ int64(saved.Date),
Date: saved.Date,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID, Stars: gift.Stars, ConvertStars: saved.ConvertStars,
Title: gift.Title, Sticker: &sticker, Message: saved.Message,
FromUserID: saved.FromUserID, PeerUserID: saved.Owner.ID, Saved: true,
CanUpgrade: gift.UpgradeStars > 0, PrepaidUpgrade: saved.PrepaidUpgradeStars > 0,
UpgradePriceStars: gift.UpgradeStars, UpgradeStars: saved.PrepaidUpgradeStars,
},
}},
})
if err != nil {
t.Fatalf("create collectible source message: %v", err)
}
saved.MsgID = sent.RecipientMessage.ID
id, err := gifts.Create(ctx, saved)
if err != nil {
t.Fatalf("create saved gift: %v", err)
}
saved.ID = id
return saved
}
func upgradedSourceEditForUser(result domain.StarGiftUpgradeResult, userID int64) domain.EditedMessageForUser {
for _, edit := range result.SourceEdits {
if edit.UserID == userID {
return edit
}
}
return domain.EditedMessageForUser{UserID: userID}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,227 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// markCraftInputMessagesTx makes the chat projection part of the same commit
// as the craft outcome. TDesktop derives the Craft entry directly from the
// messageActionStarGiftUnique snapshot, so changing only peer_star_gifts and
// unique_star_gifts would leave an already-burned input actionable.
func (s *StarGiftLifecycleStore) markCraftInputMessagesTx(
ctx context.Context,
tx pgx.Tx,
req domain.StarGiftCraftRequest,
savedIDs []int64,
) ([]domain.EditedMessageForUser, []int32, error) {
edits := make([]domain.EditedMessageForUser, 0, len(savedIDs)*2)
ownerPTS := make([]int32, 0, len(savedIDs))
for _, savedID := range savedIDs {
saved, found, err := savedStarGiftByID(ctx, tx, savedID)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
saved.UniqueGiftID <= 0 || saved.UpgradeMsgID <= 0 {
if err != nil {
return nil, nil, err
}
return nil, nil, domain.ErrStarGiftCraftUnavailable
}
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID)
if err != nil || !found {
if err != nil {
return nil, nil, err
}
return nil, nil, domain.ErrStarGiftCraftUnavailable
}
inputEdits, ownerPT, err := s.markCraftInputMessageTx(ctx, tx, req, saved, unique)
if err != nil {
return nil, nil, err
}
edits = append(edits, inputEdits...)
ownerPTS = append(ownerPTS, int32(ownerPT))
}
return edits, ownerPTS, nil
}
func (s *StarGiftLifecycleStore) markCraftInputMessageTx(
ctx context.Context,
tx pgx.Tx,
req domain.StarGiftCraftRequest,
saved domain.SavedStarGift,
unique domain.UniqueStarGift,
) ([]domain.EditedMessageForUser, int, error) {
q := sqlcgen.New(tx)
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
OwnerUserID: req.UserID,
BoxID: int32(saved.UpgradeMsgID),
PeerType: string(domain.PeerTypeUser),
PeerID: saved.FromUserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, domain.ErrStarGiftCraftUnavailable
}
return nil, 0, fmt.Errorf("lock craft input message: %w", err)
}
boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
OwnerUserIds: privateMessageOwnerIDs(req.UserID, saved.FromUserID),
MessageSenderID: target.MessageSenderID,
PrivateMessageID: target.PrivateMessageID,
})
if err != nil {
return nil, 0, fmt.Errorf("list craft input message boxes: %w", err)
}
if len(boxes) == 0 {
return nil, 0, domain.ErrStarGiftCraftUnavailable
}
edits := make([]domain.EditedMessageForUser, 0, len(boxes))
ownerPTS := 0
var privateMediaJSON []byte
for _, box := range boxes {
media, err := decodeMessageMedia(box.MediaJson)
if err != nil {
return nil, 0, fmt.Errorf("decode craft input message media: %w", err)
}
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil ||
media.ServiceAction.StarGiftUnique.Gift.ID != unique.ID {
return nil, 0, fmt.Errorf("craft input message %d has invalid unique gift projection", box.BoxID)
}
action := media.ServiceAction.StarGiftUnique
action.Gift = unique
action.Saved = saved.LifecycleStatus.Live() && !saved.Unsaved
action.CanExportAt = saved.CanExportAt
action.TransferStars = saved.TransferStars
action.CanTransferAt = saved.CanTransferAt
action.CanResellAt = saved.CanResellAt
action.DropOriginalDetailsStars = saved.DropOriginalDetailsStars
action.CanCraftAt = saved.CanCraftAt
mediaJSON, err := encodeMessageMedia(media)
if err != nil {
return nil, 0, fmt.Errorf("encode craft input message media: %w", err)
}
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
if err != nil {
return nil, 0, fmt.Errorf("allocate craft input edit pts: %w", err)
}
tag, err := tx.Exec(ctx, `
UPDATE message_boxes SET media=$3,pts=$4
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts))
if err != nil {
return nil, 0, fmt.Errorf("update craft input message box: %w", err)
}
if tag.RowsAffected() != 1 {
return nil, 0, fmt.Errorf("update craft input message box lost row")
}
msg, err := messageFromVisibleBoxRow(box)
if err != nil {
return nil, 0, err
}
msg.Media = media
msg.Pts = pts
if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil {
return nil, 0, err
}
event := domain.UpdateEvent{UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage,
Pts: pts, PtsCount: 1, Date: req.Date, Message: msg}
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
return nil, 0, fmt.Errorf("append craft input edit event: %w", err)
}
dispatchAuthKeyID := [8]byte{}
dispatchSessionID := int64(0)
if msg.OwnerUserID == req.UserID {
dispatchAuthKeyID = req.OriginAuthKeyID
dispatchSessionID = req.OriginSessionID
ownerPTS = pts
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage),
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), ExcludeSessionID: dispatchSessionID,
}); err != nil {
return nil, 0, fmt.Errorf("enqueue craft input edit: %w", err)
}
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
privateMediaJSON = mediaJSON
}
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
}
if ownerPTS <= 0 || len(privateMediaJSON) == 0 {
return nil, 0, fmt.Errorf("craft input message missing owner projection")
}
if _, err := tx.Exec(ctx, `
UPDATE private_messages SET media=$3
WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil {
return nil, 0, fmt.Errorf("update craft input private message: %w", err)
}
return edits, ownerPTS, nil
}
func (s *StarGiftLifecycleStore) loadCraftInputMessageReplays(
ctx context.Context,
req domain.StarGiftCraftRequest,
savedIDs []int64,
ptsValues []int32,
) ([]domain.EditedMessageForUser, error) {
if len(savedIDs) != len(ptsValues) {
return nil, domain.ErrStarGiftCraftUnavailable
}
edits := make([]domain.EditedMessageForUser, 0, len(savedIDs))
for i, savedID := range savedIDs {
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
saved.UpgradeMsgID <= 0 || ptsValues[i] <= 0 {
if err != nil {
return nil, err
}
return nil, domain.ErrStarGiftCraftUnavailable
}
var privateMessageID, messageSenderID int64
err = s.db.QueryRow(ctx, `
SELECT private_message_id,message_sender_id FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND peer_type='user' AND peer_id=$3 AND NOT deleted`,
req.UserID, saved.UpgradeMsgID, saved.FromUserID).Scan(&privateMessageID, &messageSenderID)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return nil, fmt.Errorf("load craft input replay message: %w", err)
}
boxes, err := sqlcgen.New(s.db).ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
OwnerUserIds: []int64{req.UserID}, MessageSenderID: messageSenderID, PrivateMessageID: privateMessageID,
})
if err != nil {
return nil, fmt.Errorf("load craft input replay box: %w", err)
}
if len(boxes) != 1 || int(boxes[0].BoxID) != saved.UpgradeMsgID {
return nil, domain.ErrStarGiftCraftUnavailable
}
var eventDate int
err = s.db.QueryRow(ctx, `
SELECT date FROM user_update_events
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`,
req.UserID, ptsValues[i], saved.UpgradeMsgID).Scan(&eventDate)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrStarGiftCraftUnavailable
}
return nil, fmt.Errorf("load craft input replay event: %w", err)
}
msg, err := messageFromVisibleBoxRow(boxes[0])
if err != nil {
return nil, err
}
msg.Pts = int(ptsValues[i])
event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage,
Pts: int(ptsValues[i]), PtsCount: 1, Date: eventDate, Message: msg}
edits = append(edits, domain.EditedMessageForUser{UserID: req.UserID, Message: msg, Event: event})
}
return edits, nil
}

View file

@ -0,0 +1,282 @@
package postgres
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
func (s *StarGiftLifecycleStore) PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) {
hash = strings.TrimSpace(hash)
if s == nil || s.db == nil || !validLifecyclePeer(owner) || len(hash) < 32 || len(hash) > 256 {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
row := s.db.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id,
p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num,
p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at,
p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i
JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[])
FROM peer_star_gifts p WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.prepaid_upgrade_hash=$3`,
string(owner.Type), owner.ID, hash)
saved, err := scanSavedStarGift(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID != 0 || saved.PrepaidUpgradeStars != 0 {
if err != nil {
return domain.SavedStarGift{}, 0, err
}
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
revision, err := locklessActiveCollectibleRevision(ctx, s.db, saved.GiftID)
if err != nil || revision.UpgradeStars <= 0 || revision.Issued >= revision.SupplyTotal {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
return saved, revision.UpgradeStars, nil
}
func locklessActiveCollectibleRevision(ctx context.Context, db interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, giftID int64) (domain.StarGiftCollectibleRevision, error) {
var revision domain.StarGiftCollectibleRevision
var status string
err := db.QueryRow(ctx, `SELECT r.id,r.gift_id,r.upgrade_stars,r.supply_total,r.issued,r.slug_prefix,r.status
FROM star_gift_catalog c JOIN star_gift_collectible_revisions r ON r.id=c.collectible_revision_id
WHERE c.gift_id=$1`, giftID).Scan(&revision.ID, &revision.GiftID, &revision.UpgradeStars,
&revision.SupplyTotal, &revision.Issued, &revision.SlugPrefix, &status)
if err != nil || status != "published" {
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable
}
return revision, nil
}
func (s *StarGiftLifecycleStore) PrepayStarGiftUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) {
req.Hash, req.CommandKey = strings.TrimSpace(req.Hash), strings.TrimSpace(req.CommandKey)
if s == nil || s.messages == nil || req.PayerUserID <= 0 || !validLifecyclePeer(req.Owner) ||
len(req.Hash) < 32 || len(req.Hash) > 256 || req.FormID == 0 || req.Date <= 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.ChargeStars < 0 {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
if replay, found, err := s.loadPrepaidUpgradeReplay(ctx, req, domain.SendPrivateTextResult{}); err != nil || found {
return replay, err
}
if req.ChargeStars <= 0 {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
target, price, err := s.PrepaidUpgradeTarget(ctx, req.Owner, req.Hash)
if err != nil || price != req.ChargeStars {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
fingerprint := sha256.Sum256([]byte(fmt.Sprintf("telesrv:star-gift-prepay:v2:%d:%s:%d:%s:%d:%d", req.PayerUserID,
req.Owner.Type, req.Owner.ID, req.Hash, req.FormID, req.ChargeStars)))
placeholder := &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{Saved: true, CanUpgrade: true, UpgradeSeparate: true}}}
messageSenderID, recipientUserID := req.PayerUserID, req.Owner.ID
if req.Owner.Type == domain.PeerTypeChannel {
messageSenderID, recipientUserID = domain.OfficialSystemUserID, req.PayerUserID
}
messageReq := domain.SendPrivateTextRequest{SenderUserID: messageSenderID, RecipientUserID: recipientUserID,
RandomID: lifecycleCommandRandomID("prepay", req.PayerUserID, req.Owner.ID, req.Hash), Media: placeholder, Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.PayerUserID,
IdempotencyFingerprint: fingerprint[:]}
var result domain.StarGiftPrepaidUpgradeResult
hooks := privateSendTxHooks{before: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) error {
locked, err := lockSavedStarGiftByPrepayHash(ctx, tx, req.Owner, req.Hash)
if err != nil || locked.ID != target.ID || !locked.LifecycleStatus.Live() || locked.UniqueGiftID != 0 || locked.PrepaidUpgradeStars != 0 {
return domain.ErrStarGiftCollectibleUnavailable
}
revision, err := lockActiveCollectibleRevision(ctx, tx, locked.GiftID)
if err != nil || revision.UpgradeStars != req.ChargeStars || revision.Issued >= revision.SupplyTotal {
return domain.ErrStarGiftCollectibleUnavailable
}
balance, err := s.debitLifecycleAmount(ctx, tx, req.PayerUserID,
domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars},
domain.StarsReasonGiftPrepaid, req.Owner, req.Date, "Prepaid star gift upgrade")
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET prepaid_upgrade_stars=$2,prepaid_upgrade_hash='' WHERE id=$1`, locked.ID, req.ChargeStars); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_prepaid_upgrade_commands(payer_user_id,command_key,saved_gift_id,form_id,charge_stars,balance_after,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7)`, req.PayerUserID, req.CommandKey, locked.ID, req.FormID, req.ChargeStars, balance.Balance, req.Date); err != nil {
return err
}
gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, locked.RevisionID)
if err != nil || !found {
return domain.ErrStarGiftCollectibleUnavailable
}
sticker := gift.Sticker
action := &domain.MessageStarGiftAction{
GiftID: gift.ID, Stars: gift.Stars, ConvertStars: locked.ConvertStars, Title: gift.Title, Sticker: &sticker,
FromUserID: req.PayerUserID, To: req.Owner, SavedID: locked.SavedID, Saved: true, CanUpgrade: true,
PrepaidUpgrade: true, UpgradeSeparate: true, UpgradePriceStars: req.ChargeStars,
UpgradeStars: req.ChargeStars, GiftMsgID: locked.MsgID,
}
if req.Owner.Type == domain.PeerTypeChannel {
action.PeerChannelID = req.Owner.ID
} else {
action.PeerUserID = req.Owner.ID
}
messageReq.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{
GiftID: action.GiftID, Stars: action.Stars, ConvertStars: action.ConvertStars, Title: action.Title,
Sticker: action.Sticker, FromUserID: action.FromUserID, PeerUserID: action.PeerUserID,
PeerChannelID: action.PeerChannelID, To: action.To, SavedID: action.SavedID, Saved: action.Saved,
CanUpgrade: action.CanUpgrade, PrepaidUpgrade: action.PrepaidUpgrade, UpgradeSeparate: action.UpgradeSeparate,
UpgradePriceStars: action.UpgradePriceStars, UpgradeStars: action.UpgradeStars, GiftMsgID: action.GiftMsgID}}}
locked.PrepaidUpgradeStars, locked.PrepaidUpgradeHash = req.ChargeStars, ""
result.Saved, result.Balance = locked, balance
return nil
}, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
if req.Owner.Type != domain.PeerTypeChannel {
return nil
}
action := messageReq.Media.ServiceAction.StarGift
return NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.Owner.ID, req.PayerUserID,
result.Saved.SavedID, req.Date, domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: action})
}}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadPrepaidUpgradeReplay(ctx, req, sent); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftPrepaidUpgradeResult{}, err
}
result.Send, result.Duplicate = sent, sent.Duplicate
if sent.Duplicate {
replay, _, replayErr := s.loadPrepaidUpgradeReplay(ctx, req, sent)
return replay, replayErr
}
return result, nil
}
func lockSavedStarGiftByPrepayHash(ctx context.Context, tx pgx.Tx, owner domain.Peer, hash string) (domain.SavedStarGift, error) {
row := tx.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id,
p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num,
p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at,
p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i
JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[])
FROM peer_star_gifts p WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.prepaid_upgrade_hash=$3 FOR UPDATE`,
string(owner.Type), owner.ID, hash)
saved, err := scanSavedStarGift(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGift{}, domain.ErrStarGiftCollectibleUnavailable
}
return saved, err
}
func (s *StarGiftLifecycleStore) loadPrepaidUpgradeReplay(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest, sent domain.SendPrivateTextResult) (domain.StarGiftPrepaidUpgradeResult, bool, error) {
var savedID, balance int64
err := s.db.QueryRow(ctx, `SELECT saved_gift_id,balance_after FROM star_gift_prepaid_upgrade_commands WHERE payer_user_id=$1 AND command_key=$2`,
req.PayerUserID, req.CommandKey).Scan(&savedID, &balance)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftPrepaidUpgradeResult{}, false, nil
}
if err != nil {
return domain.StarGiftPrepaidUpgradeResult{}, false, err
}
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found {
return domain.StarGiftPrepaidUpgradeResult{}, false, domain.ErrStarGiftCollectibleUnavailable
}
return domain.StarGiftPrepaidUpgradeResult{Saved: saved, Balance: domain.StarsBalance{UserID: req.PayerUserID, Balance: balance}, Send: sent, Duplicate: true}, true, nil
}
func (s *StarGiftLifecycleStore) DropStarGiftOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) {
req.CommandKey = strings.TrimSpace(req.CommandKey)
if s == nil || s.db == nil || req.UserID <= 0 || !req.Ref.Valid() ||
(req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) || !validLifecyclePeer(req.Ref.Owner) ||
req.FormID == 0 || req.Date <= 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.ChargeStars < 0 {
return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable
}
if replay, found, err := s.loadDropDetailsReplay(ctx, req); err != nil || found {
return replay, err
}
if req.ChargeStars <= 0 {
return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable
}
var result domain.StarGiftDropOriginalDetailsResult
err := withTx(ctx, s.db, "drop star gift original details", func(tx pgx.Tx) error {
saved, unique, err := lockOwnedUniqueStarGift(ctx, tx, req.UserID, req.Ref)
if err != nil || saved.DropOriginalDetailsStars != req.ChargeStars || !unique.KeepOriginalDetails {
return domain.ErrStarGiftCollectibleUnavailable
}
balance, err := s.debitLifecycleAmount(ctx, tx, req.UserID,
domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars},
domain.StarsReasonGiftDrop, saved.Owner, req.Date, "Drop star gift original details")
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET keep_original_details=false,updated_at=now() WHERE id=$1`, unique.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET drop_original_details_stars=0 WHERE id=$1`, saved.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_drop_details_commands(user_id,command_key,saved_gift_id,unique_gift_id,form_id,charge_stars,balance_after,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, req.UserID, req.CommandKey, saved.ID, unique.ID, req.FormID, req.ChargeStars, balance.Balance, req.Date); err != nil {
return err
}
saved.DropOriginalDetailsStars, unique.KeepOriginalDetails = 0, false
result = domain.StarGiftDropOriginalDetailsResult{Saved: saved, Unique: unique, Balance: balance}
return nil
})
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadDropDetailsReplay(ctx, req); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftDropOriginalDetailsResult{}, err
}
return result, nil
}
func (s *StarGiftLifecycleStore) loadDropDetailsReplay(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, bool, error) {
var savedID, uniqueID, balance int64
err := s.db.QueryRow(ctx, `SELECT saved_gift_id,unique_gift_id,balance_after FROM star_gift_drop_details_commands WHERE user_id=$1 AND command_key=$2`,
req.UserID, req.CommandKey).Scan(&savedID, &uniqueID, &balance)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftDropOriginalDetailsResult{}, false, nil
}
if err != nil {
return domain.StarGiftDropOriginalDetailsResult{}, false, err
}
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found {
return domain.StarGiftDropOriginalDetailsResult{}, false, domain.ErrStarGiftCollectibleUnavailable
}
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID)
if err != nil || !found {
return domain.StarGiftDropOriginalDetailsResult{}, false, domain.ErrStarGiftCollectibleUnavailable
}
return domain.StarGiftDropOriginalDetailsResult{Saved: saved, Unique: unique,
Balance: domain.StarsBalance{UserID: req.UserID, Balance: balance}, Duplicate: true}, true, nil
}
func savedStarGiftByID(ctx context.Context, db interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, savedID int64) (domain.SavedStarGift, bool, error) {
row := db.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id,
p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num,
p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at,
p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i
JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[])
FROM peer_star_gifts p WHERE p.id=$1`, savedID)
saved, err := scanSavedStarGift(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGift{}, false, nil
}
return saved, err == nil, err
}

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"testing"
"time"
@ -57,13 +58,16 @@ func TestStarGiftStorePostgres(t *testing.T) {
})
// 创建三份礼物msg_id 递增)。
savedIDs := make([]int64, 3)
for i := 0; i < 3; i++ {
if _, err := st.Create(ctx, domain.SavedStarGift{
savedID, err := st.Create(ctx, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 100 + i,
Date: 1700000000 + i, ConvertStars: 50,
}); err != nil {
})
if err != nil {
t.Fatalf("create gift #%d: %v", i, err)
}
savedIDs[i] = savedID
}
// active revision 更新后,已收到的礼物必须继续固定到购买瞬间的 immutable revision。
@ -113,6 +117,35 @@ func TestStarGiftStorePostgres(t *testing.T) {
t.Fatalf("page2 = %d next %q, want 1 + empty (terminal)", len(page2.Gifts), page2.NextOffset)
}
// 资料页顺序:完整 pin vector 的顺序必须成为列表前缀;游标即使切在
// pinned block 内或 pinned/unpinned 边界,也不能重复或漏项。
if err := st.SetPinned(ctx, ownerPeer, []int64{savedIDs[0], savedIDs[2]}); err != nil {
t.Fatalf("set pinned profile order: %v", err)
}
wantMsgIDs := []int{100, 102, 101}
gotMsgIDs := make([]int, 0, len(wantMsgIDs))
offset := ""
for pageNumber := 0; ; pageNumber++ {
page, err := st.ListByOwner(ctx, ownerPeer, false, offset, 1)
if err != nil {
t.Fatalf("list pinned page %d: %v", pageNumber, err)
}
if page.Count != 3 || len(page.Gifts) != 1 {
t.Fatalf("pinned page %d = %+v, want count=3 and one gift", pageNumber, page)
}
gotMsgIDs = append(gotMsgIDs, page.Gifts[0].MsgID)
if page.NextOffset == "" {
break
}
offset = page.NextOffset
}
if !slices.Equal(gotMsgIDs, wantMsgIDs) {
t.Fatalf("pinned paged msg ids = %v, want %v", gotMsgIDs, wantMsgIDs)
}
if err := st.SetPinned(ctx, ownerPeer, nil); err != nil {
t.Fatalf("clear pinned profile order: %v", err)
}
// 隐藏 msg_id=101 → excludeUnsaved 列表少一份。
if ok, err := st.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 101}, true); err != nil || !ok {
t.Fatalf("set unsaved = %v err %v", ok, err)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,865 @@
package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
"telesrv/internal/domain"
)
func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
users := NewUserStore(pool)
buyer := createTestUser(t, ctx, users, "+1881"+suffix+"01", "GiftBuyer", "")
owner := createTestUser(t, ctx, users, "+1881"+suffix+"02", "GiftOwner", "")
offerBuyer := createTestUser(t, ctx, users, "+1881"+suffix+"03", "OfferBuyer", "")
resaleBuyer := createTestUser(t, ctx, users, "+1881"+suffix+"04", "ResaleBuyer", "")
loser := createTestUser(t, ctx, users, "+1881"+suffix+"05", "AuctionLoser", "")
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
stars := NewStarsStore(pool)
for _, user := range []domain.User{buyer, owner, offerBuyer, resaleBuyer, loser} {
if _, _, err := stars.EnsureGrant(ctx, user.ID, 10000, now); err != nil {
t.Fatalf("grant stars to %d: %v", user.ID, err)
}
}
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Lifecycle " + suffix, Stars: 50, ConvertStars: 20, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "lifecycle.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "lifecycle"), Animation: collectibleTestAnimation("lifecycle.tgs"),
Actor: "integration", CommandID: "lifecycle-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create lifecycle catalog: %v", err)
}
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 20, SlugPrefix: "life-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
Document: collectibleTestDocumentPtr(baseDocumentID+2, "crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "crafted"), Animation: collectibleTestAnimationPtr("crafted.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: 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)
}
messages := NewMessageStore(pool)
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
StarsProceedsPermille: 900, TONProceedsPermille: 900,
}))
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500,
}))
purchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer,
GiftID: entry.Gift.ID, CommandKey: "purchase-" + suffix, Date: now, Message: "hello"})
purchased, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
if err != nil {
t.Fatalf("purchase gift: %v", err)
}
if purchased.Saved.ID <= 0 || purchased.Saved.MsgID <= 0 || purchased.Saved.PrepaidUpgradeHash == "" || purchased.Balance.Balance != 9950 {
t.Fatalf("purchase result = %+v", purchased)
}
ordinaryAction := purchased.Send.RecipientMessage.Media.ServiceAction.StarGift
if ordinaryAction == nil || !ordinaryAction.CanUpgrade || ordinaryAction.PrepaidUpgrade ||
ordinaryAction.UpgradePriceStars != 100 || ordinaryAction.UpgradeStars != 0 {
t.Fatalf("ordinary purchase action mixed paid price with prepaid amount: %+v", ordinaryAction)
}
replayedPurchase, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
if err != nil || !replayedPurchase.Duplicate || replayedPurchase.Saved.ID != purchased.Saved.ID || replayedPurchase.Balance.Balance != 9950 ||
replayedPurchase.Send.SenderMessage.ID != purchased.Send.SenderMessage.ID ||
replayedPurchase.Send.RecipientMessage.ID != purchased.Send.RecipientMessage.ID {
t.Fatalf("purchase replay = %+v err %v", replayedPurchase, err)
}
target, price, err := lifecycle.PrepaidUpgradeTarget(ctx, ownerPeer, purchased.Saved.PrepaidUpgradeHash)
if err != nil || target.ID != purchased.Saved.ID || price != 100 {
t.Fatalf("prepaid target = %+v price %d err %v", target, price, err)
}
prepaid, err := lifecycle.PrepayStarGiftUpgrade(ctx, domain.StarGiftPrepaidUpgradeRequest{
PayerUserID: buyer.ID, Owner: ownerPeer, Hash: purchased.Saved.PrepaidUpgradeHash,
ChargeStars: 100, FormID: 11002, CommandKey: "prepay-" + suffix, Date: now + 1,
})
if err != nil || prepaid.Saved.PrepaidUpgradeStars != 100 || prepaid.Saved.PrepaidUpgradeHash != "" || prepaid.Balance.Balance != 9850 {
t.Fatalf("prepay upgrade = %+v err %v", prepaid, err)
}
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID},
RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "upgrade-" + suffix, Date: now + 2,
})
if err != nil {
t.Fatalf("upgrade prepaid gift: %v", err)
}
if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 ||
upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails {
t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique)
}
upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) ||
ownerSourceEdit.Message.Media == nil || ownerSourceEdit.Message.Media.ServiceAction == nil ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Saved.UpgradeMsgID ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.CanUpgrade {
t.Fatalf("upgrade message linkage = action %+v source edit %+v", upgradeAction, ownerSourceEdit)
}
dropped, err := lifecycle.DropStarGiftOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID},
ChargeStars: 25, FormID: 11003, CommandKey: "drop-" + suffix, Date: now + 3,
})
if err != nil || dropped.Unique.KeepOriginalDetails || dropped.Saved.DropOriginalDetailsStars != 0 || dropped.Balance.Balance != 9975 {
t.Fatalf("drop original details = %+v err %v", dropped, err)
}
// Expiry is driven by the background sweep, refunds exactly once and emits a
// durable declined/expired service message even when no user opens the offer.
expiring, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID,
Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 300},
Duration: 120, RandomID: 22001, Date: now + 10,
})
if err != nil || expiring.Balance.Balance != 9700 {
t.Fatalf("send expiring offer = %+v err %v", expiring, err)
}
if err := lifecycle.SweepStarGiftLifecycle(ctx, now+131, 1000); err != nil {
t.Fatalf("sweep expired offer: %v", err)
}
var expiredStatus string
var resolutionNotified bool
if err := pool.QueryRow(ctx, `SELECT status,resolution_notified FROM star_gift_offers WHERE id=$1`, expiring.Offer.ID).
Scan(&expiredStatus, &resolutionNotified); err != nil || expiredStatus != "expired" || !resolutionNotified {
t.Fatalf("expired offer state = %q notified %v err %v", expiredStatus, resolutionNotified, err)
}
if balance, err := stars.GetBalance(ctx, offerBuyer.ID); err != nil || balance.Balance != 10000 {
t.Fatalf("expired offer refund balance = %+v err %v", balance, err)
}
// TON offers use the same durable offer state machine, but only mutate the
// internal telesrv TON ledger. Idempotent replay must report that ledger's
// balance instead of accidentally projecting the buyer's Stars balance.
tonOfferReq := domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID,
Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 300},
Duration: 120, RandomID: 22003, Date: now + 132}
tonOffer, err := lifecycle.SendStarGiftOffer(ctx, tonOfferReq)
if err != nil || tonOffer.Balance.Balance != 999700 {
t.Fatalf("send TON offer = %+v err %v", tonOffer, err)
}
tonOfferReplay, err := lifecycle.SendStarGiftOffer(ctx, tonOfferReq)
if err != nil || !tonOfferReplay.Duplicate || tonOfferReplay.Balance.Balance != 999700 {
t.Fatalf("replay TON offer = %+v err %v", tonOfferReplay, err)
}
if _, err := lifecycle.ResolveStarGiftOffer(ctx, domain.StarGiftResolveOfferRequest{
OwnerUserID: owner.ID, OfferMsgID: tonOffer.Offer.OfferMsgID, Decline: true, Date: now + 133,
}); err != nil {
t.Fatalf("decline TON offer: %v", err)
}
if balance, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || balance != 1_000_000 {
t.Fatalf("declined TON offer refund balance = %d err %v", balance, err)
}
acceptedOffer, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID,
Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 300},
Duration: 120, RandomID: 22002, Date: now + 140,
})
if err != nil {
t.Fatalf("send accepted offer: %v", err)
}
accepted, err := lifecycle.ResolveStarGiftOffer(ctx, domain.StarGiftResolveOfferRequest{
OwnerUserID: owner.ID, OfferMsgID: acceptedOffer.Offer.OfferMsgID, Date: now + 141,
})
if err != nil || accepted.Offer.Status != "accepted" || accepted.Unique.Owner.ID != offerBuyer.ID || accepted.Saved.MsgID <= 0 {
t.Fatalf("accept offer = %+v err %v", accepted, err)
}
if balance, err := stars.GetBalance(ctx, owner.ID); err != nil || balance.Balance != 10245 {
t.Fatalf("offer seller balance = %+v err %v", balance, err)
}
var offerCommission int64
if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`,
fmt.Sprintf("offer:%d", acceptedOffer.Offer.ID)).Scan(&offerCommission); err != nil || offerCommission != 30 {
t.Fatalf("accepted Stars offer commission = %d err %v", offerCommission, err)
}
listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: offerBuyer.ID,
Ref: domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: offerBuyer.ID}, MsgID: accepted.Saved.MsgID},
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, Date: now + 142,
})
if err != nil || listed.ResellAmount == nil || listed.ResellAmount.Currency != domain.StarGiftCurrencyTON {
t.Fatalf("TON listing = %+v err %v", listed, err)
}
tonBefore, err := lifecycle.TonBalance(ctx, resaleBuyer.ID)
if err != nil || tonBefore != 1_000_000 {
t.Fatalf("resale buyer TON grant = %d err %v", tonBefore, err)
}
resold, err := lifecycle.PurchaseResaleStarGift(ctx, domain.StarGiftResalePurchaseRequest{
BuyerUserID: resaleBuyer.ID, Slug: listed.Slug, To: domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID},
Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, FormID: 11004,
CommandKey: "resale-" + suffix, Date: now + 143,
})
if err != nil || resold.Unique.Owner.ID != resaleBuyer.ID || resold.Balance.Balance != 999000 || resold.Saved.TransferStars != 25 {
t.Fatalf("TON resale = %+v err %v", resold, err)
}
selected, valid := domain.CollectibleEmojiStatus(resold.Unique)
if !valid {
t.Fatalf("resold collectible cannot project emoji status: %+v", resold.Unique)
}
if _, err := users.UpdateEmojiStatus(ctx, resaleBuyer.ID, domain.UserEmojiStatus{
DocumentID: selected.DocumentID,
Collectible: selected,
}); err != nil {
t.Fatalf("wear resold collectible: %v", err)
}
updateEvents := NewUpdateEventStore(pool)
statusPtsBeforeTransfer, err := updateEvents.MaxContiguousPts(ctx, resaleBuyer.ID)
if err != nil {
t.Fatalf("emoji status pts before transfer: %v", err)
}
if sellerTON, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || sellerTON != 1_000_900 {
t.Fatalf("TON seller local balance = %d err %v", sellerTON, err)
}
var resaleCommission int64
if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, "resale-"+suffix).
Scan(&resaleCommission); err != nil || resaleCommission != 100 {
t.Fatalf("TON resale commission = %d err %v", resaleCommission, err)
}
tonPage, err := lifecycle.TonTransactions(ctx, resaleBuyer.ID, "", 20)
if err != nil || tonPage.Balance != 999000 || len(tonPage.Transactions) < 2 {
t.Fatalf("TON ledger page = %+v err %v", tonPage, err)
}
transferred, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{ActorUserID: resaleBuyer.ID,
Ref: domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}, MsgID: resold.Saved.MsgID},
To: ownerPeer, ChargeStars: 25, FormID: 11005, CommandKey: "transfer-back-" + suffix, Date: now + 144,
})
if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 {
t.Fatalf("paid transfer = %+v err %v", transferred, err)
}
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)
}
statusEvents, err := updateEvents.ListAfter(ctx, resaleBuyer.ID, statusPtsBeforeTransfer, 20)
if err != nil {
t.Fatalf("load collectible invalidation event: %v", err)
}
var clearEvent domain.UpdateEvent
for _, event := range statusEvents {
if event.Type == domain.UpdateEventUserEmojiStatus {
clearEvent = event
break
}
}
if clearEvent.Pts == 0 || !clearEvent.EmojiStatus.Empty() || clearEvent.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}) {
t.Fatalf("collectible invalidation event = %+v", clearEvent)
}
var clearOutboxCount int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBuyer.ID, clearEvent.Pts).Scan(&clearOutboxCount); err != nil || clearOutboxCount != 1 {
t.Fatalf("collectible invalidation outbox count=%d err=%v, want 1", clearOutboxCount, err)
}
// A second prepaid collectible makes craft chance exactly 1000‰. Success
// preserves the first aggregate as crafted and burns the other input. The
// fresh payment intent must create another gift even though buyer, owner and
// catalog gift are identical to the first purchase.
secondPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer,
GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "purchase-second-" + suffix, Date: now + 145})
secondPurchase, err := lifecycle.PurchaseStarGift(ctx, secondPurchaseReq)
if err != nil {
t.Fatalf("purchase second prepaid gift: %v", err)
}
prepaidAction := secondPurchase.Send.RecipientMessage.Media.ServiceAction.StarGift
if prepaidAction == nil || !prepaidAction.PrepaidUpgrade || prepaidAction.UpgradePriceStars != 100 || prepaidAction.UpgradeStars != 100 {
t.Fatalf("prepaid purchase action lost price/entitlement split: %+v", prepaidAction)
}
secondUpgrade, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID,
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: secondPurchase.Saved.MsgID}, RequirePrepaid: true,
CommandKey: "upgrade-second-" + suffix, Date: now + 146,
})
if err != nil {
t.Fatalf("upgrade second prepaid gift: %v", err)
}
listedForCraft, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID,
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, Date: now + 146,
})
if err != nil || listedForCraft.ResellAmount == nil || listedForCraft.ResellAmount.Amount != 125 {
t.Fatalf("list craft input = %+v err %v", listedForCraft, err)
}
loserBalanceBeforeOffer, err := stars.GetBalance(ctx, loser.ID)
if err != nil {
t.Fatalf("craft offer buyer balance: %v", err)
}
pendingCraftOffer, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: loser.ID,
Owner: ownerPeer, Slug: transferred.Unique.Slug,
Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125},
Duration: 120, RandomID: 22003, Date: now + 146,
})
if err != nil || pendingCraftOffer.Offer.Status != "pending" {
t.Fatalf("pending craft offer = %+v err %v", pendingCraftOffer, err)
}
resolvedCraftIDs, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
})
if err != nil || len(resolvedCraftIDs) != 2 || resolvedCraftIDs[0] != transferred.Saved.ID || resolvedCraftIDs[1] != secondUpgrade.Saved.ID {
t.Fatalf("resolve mixed craft refs = %v err %v", resolvedCraftIDs, err)
}
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID},
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
}); !errors.Is(err, domain.ErrStarGiftNotFound) {
t.Fatalf("upgrade message id lookup err = %v, want ErrStarGiftNotFound", err)
}
if saved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{
Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID,
}); err != nil || found {
t.Fatalf("upgrade message id resolved a gift: saved=%+v found=%v err=%v", saved, found, err)
}
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.MsgID},
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
}); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("duplicate official identities err = %v", err)
}
crafted, err := lifecycle.CraftStarGift(ctx, domain.StarGiftCraftRequest{UserID: owner.ID,
Refs: []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
// TDesktop sends collectibles without a manage id as the official
// inputSavedStarGiftSlug alias.
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
}, CommandKey: "craft-" + suffix, Date: now + 147,
})
if err != nil || !crafted.Success || crafted.Chance != 1000 || crafted.Gift == nil || !crafted.Gift.Crafted || crafted.Send.RecipientMessage.ID <= 0 {
t.Fatalf("craft result = %+v err %v", crafted, err)
}
craftedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, transferred.Unique.ID)
craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit)
burnedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, secondUpgrade.Unique.ID)
burnedInputAction := starGiftUniqueActionFromEdit(burnedInputEdit)
if craftedInputAction == nil || !craftedInputAction.Gift.Crafted || craftedInputAction.Gift.Burned ||
craftedInputAction.Gift.CraftChancePermille != 0 || !craftedInputAction.Saved || craftedInputAction.CanCraftAt != 0 {
t.Fatalf("crafted input message projection = %+v", craftedInputAction)
}
if burnedInputAction == nil || !burnedInputAction.Gift.Burned || burnedInputAction.Gift.CraftChancePermille != 0 ||
burnedInputAction.Saved || burnedInputAction.CanCraftAt != 0 {
t.Fatalf("burned input message projection = %+v", burnedInputAction)
}
craftReq := domain.StarGiftCraftRequest{UserID: owner.ID,
Refs: []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
}, CommandKey: "craft-" + suffix, Date: now + 147,
}
craftedReplay, err := lifecycle.CraftStarGift(ctx, craftReq)
if err != nil || !craftedReplay.Duplicate || !craftedReplay.Success || craftedReplay.Gift == nil ||
craftedReplay.Send.RecipientMessage.ID != crafted.Send.RecipientMessage.ID ||
craftedSourceEditForUserAndGift(craftedReplay, owner.ID, transferred.Unique.ID).Event.Pts != craftedInputEdit.Event.Pts ||
craftedSourceEditForUserAndGift(craftedReplay, owner.ID, secondUpgrade.Unique.ID).Event.Pts != burnedInputEdit.Event.Pts {
t.Fatalf("craft success replay = %+v err %v", craftedReplay, err)
}
var craftListings, resaleAvailability int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings WHERE unique_gift_id=ANY($1::bigint[])`,
[]int64{transferred.Unique.ID, secondUpgrade.Unique.ID}).Scan(&craftListings); err != nil || craftListings != 0 {
t.Fatalf("craft input listings = %d err %v", craftListings, err)
}
if err := pool.QueryRow(ctx, `SELECT availability_resale FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&resaleAvailability); err != nil || resaleAvailability != 0 {
t.Fatalf("craft resale projection = %d err %v", resaleAvailability, err)
}
var craftOfferStatus string
if err := pool.QueryRow(ctx, `SELECT status FROM star_gift_offers WHERE id=$1`, pendingCraftOffer.Offer.ID).Scan(&craftOfferStatus); err != nil || craftOfferStatus != "cancelled" {
t.Fatalf("craft offer status = %q err %v", craftOfferStatus, err)
}
loserBalanceAfterCraft, err := stars.GetBalance(ctx, loser.ID)
if err != nil || loserBalanceAfterCraft.Balance != loserBalanceBeforeOffer.Balance {
t.Fatalf("craft offer refund balance = %+v err %v, want %d", loserBalanceAfterCraft, err, loserBalanceBeforeOffer.Balance)
}
var secondStatus string
if err := pool.QueryRow(ctx, `SELECT lifecycle_status FROM peer_star_gifts WHERE id=$1`, secondUpgrade.Saved.ID).Scan(&secondStatus); err != nil || secondStatus != "burned" {
t.Fatalf("second craft input status = %q err %v", secondStatus, err)
}
// A failed draw is just as terminal as success: the input aggregate and both
// users' message snapshots are burned in the outcome transaction. An exact
// retry replays the receipt, while a fresh command cannot consume it again.
thirdPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer,
GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "purchase-third-" + suffix, Date: now + 148})
thirdPurchase, err := lifecycle.PurchaseStarGift(ctx, thirdPurchaseReq)
if err != nil {
t.Fatalf("purchase third prepaid gift: %v", err)
}
thirdUpgrade, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID,
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: thirdPurchase.Saved.MsgID}, RequirePrepaid: true,
CommandKey: "upgrade-third-" + suffix, Date: now + 149,
})
if err != nil {
t.Fatalf("upgrade third prepaid gift: %v", err)
}
failingLifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000,
WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{StarsProceedsPermille: 900, TONProceedsPermille: 900}),
WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil }))
failureReq := domain.StarGiftCraftRequest{UserID: owner.ID,
Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.MsgID}},
CommandKey: "craft-fail-" + suffix, Date: now + 150,
}
failedCraft, err := failingLifecycle.CraftStarGift(ctx, failureReq)
if err != nil || failedCraft.Success || failedCraft.Chance != 500 || failedCraft.Gift != nil {
t.Fatalf("craft failure result = %+v err %v", failedCraft, err)
}
failedInputEdit := craftedSourceEditForUserAndGift(failedCraft, owner.ID, thirdUpgrade.Unique.ID)
failedInputAction := starGiftUniqueActionFromEdit(failedInputEdit)
if failedInputAction == nil || !failedInputAction.Gift.Burned || failedInputAction.Gift.CraftChancePermille != 0 ||
failedInputAction.Gift.OfferMinStars != 0 || failedInputAction.Saved || failedInputAction.CanCraftAt != 0 {
t.Fatalf("failed craft message projection = %+v", failedInputAction)
}
var failedLifecycle string
var failedUnsaved bool
var failedTransferStars int64
var failedCanExportAt, failedCanTransferAt, failedCanResellAt, failedCanCraftAt int
var failedDropStars int64
if err := pool.QueryRow(ctx, `SELECT lifecycle_status,unsaved,transfer_stars,can_export_at,can_transfer_at,
can_resell_at,drop_original_details_stars,can_craft_at FROM peer_star_gifts WHERE id=$1`, thirdUpgrade.Saved.ID).
Scan(&failedLifecycle, &failedUnsaved, &failedTransferStars, &failedCanExportAt, &failedCanTransferAt,
&failedCanResellAt, &failedDropStars, &failedCanCraftAt); err != nil || failedLifecycle != "burned" || !failedUnsaved ||
failedTransferStars != 0 || failedCanExportAt != 0 || failedCanTransferAt != 0 || failedCanResellAt != 0 ||
failedDropStars != 0 || failedCanCraftAt != 0 {
t.Fatalf("failed craft saved aggregate = status %q unsaved %v transfer %d export %d transfer_at %d resale %d drop %d craft %d err %v",
failedLifecycle, failedUnsaved, failedTransferStars, failedCanExportAt, failedCanTransferAt,
failedCanResellAt, failedDropStars, failedCanCraftAt, err)
}
var failedBurned bool
var failedChance, failedOfferMin int
if err := pool.QueryRow(ctx, `SELECT burned,craft_chance_permille,offer_min_stars FROM unique_star_gifts WHERE id=$1`, thirdUpgrade.Unique.ID).
Scan(&failedBurned, &failedChance, &failedOfferMin); err != nil || !failedBurned || failedChance != 0 || failedOfferMin != 0 {
t.Fatalf("failed craft unique aggregate = burned %v chance %d offer %d err %v", failedBurned, failedChance, failedOfferMin, err)
}
failedReplay, err := failingLifecycle.CraftStarGift(ctx, failureReq)
if err != nil || !failedReplay.Duplicate || failedReplay.Success || failedReplay.Chance != failedCraft.Chance ||
craftedSourceEditForUserAndGift(failedReplay, owner.ID, thirdUpgrade.Unique.ID).Event.Pts != failedInputEdit.Event.Pts {
t.Fatalf("craft failure replay = %+v err %v", failedReplay, err)
}
invalidRetry := failureReq
invalidRetry.CommandKey = "craft-fail-new-command-" + suffix
if _, err := failingLifecycle.CraftStarGift(ctx, invalidRetry); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
t.Fatalf("fresh command reused burned craft input: %v", err)
}
craftCandidates, err := lifecycle.ListCraftStarGifts(ctx, owner.ID, entry.Gift.ID, "", 20)
if err != nil || craftCandidates.Count != 0 || len(craftCandidates.Gifts) != 0 {
t.Fatalf("terminal craft inputs remained candidates: %+v err %v", craftCandidates, err)
}
withdrawalReq := domain.StarGiftWithdrawalRequest{UserID: owner.ID,
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, Date: now + 151}
recorded, err := lifecycle.RecordStarGiftWithdrawal(ctx, withdrawalReq, "local", "withdraw-"+suffix,
"https://telesrv.invalid/gift-withdrawal/"+suffix, now+748)
if err != nil || recorded.Status != "pending" {
t.Fatalf("record local withdrawal = %+v err %v", recorded, err)
}
completed, err := lifecycle.CompleteStarGiftWithdrawal(ctx, recorded.ProviderRequestID, now+152)
if err != nil || completed.Status != "completed" || completed.Gift.OwnerAddress == "" || completed.Gift.GiftAddress == "" {
t.Fatalf("complete local withdrawal = %+v err %v", completed, err)
}
// Auction winner reservation is consumed; the unreachable lower bid is
// refunded atomically. Award delivery is durable and includes gift_num.
auctionEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Auction " + suffix, Stars: 100, Enabled: true, Limited: true, Auction: true,
AvailabilityTotal: 1, AvailabilityRemains: 1, GiftsPerRound: 1, AuctionStartDate: now - 10,
AuctionSlug: "auction-" + suffix,
Document: collectibleTestDocument(baseDocumentID+100, "auction.tgs"),
Blob: collectibleTestBlob(baseDocumentID+100, "auction"), Animation: collectibleTestAnimation("auction.tgs"),
Actor: "integration", CommandID: "auction-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create auction catalog: %v", err)
}
winnerState, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: resaleBuyer.ID,
GiftID: auctionEntry.Gift.ID, Peer: ownerPeer, BidAmount: 200, FormID: 12001, Date: now, Message: "winner"})
if err != nil || winnerState.UserState.BidAmount != 200 {
t.Fatalf("winner bid state = %+v err %v", winnerState, err)
}
if _, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: loser.ID,
GiftID: auctionEntry.Gift.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: loser.ID},
BidAmount: 150, FormID: 12002, Date: now + 1}); err != nil {
t.Fatalf("loser bid: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE star_gift_auctions SET next_round_at=$2 WHERE gift_id=$1`, auctionEntry.Gift.ID, now+2); err != nil {
t.Fatalf("make auction round due: %v", err)
}
if err := lifecycle.SweepStarGiftLifecycle(ctx, now+2, 1000); err != nil {
t.Fatalf("settle auction sweep: %v", err)
}
acquired, err := lifecycle.StarGiftAuctionAcquired(ctx, resaleBuyer.ID, auctionEntry.Gift.ID)
if err != nil || len(acquired) != 1 || acquired[0].GiftNum != 1 || acquired[0].BidAmount != 200 {
t.Fatalf("auction acquired = %+v err %v", acquired, err)
}
if loserBalance, err := stars.GetBalance(ctx, loser.ID); err != nil || loserBalance.Balance != 10000 {
t.Fatalf("auction loser refund = %+v err %v", loserBalance, err)
}
var auctionSavedCount int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE gift_id=$1 AND gift_num=1 AND convert_stars=0`, auctionEntry.Gift.ID).
Scan(&auctionSavedCount); err != nil || auctionSavedCount != 1 {
t.Fatalf("auction saved award count = %d err %v", auctionSavedCount, err)
}
}
func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
users := NewUserStore(pool)
actor := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ChannelGiftActor", "")
if _, _, err := NewStarsStore(pool).EnsureGrant(ctx, actor.ID, 10000, now); err != nil {
t.Fatalf("grant actor stars: %v", err)
}
created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: actor.ID, Title: "Gift Channel " + suffix, Megagroup: true, Date: now,
})
if err != nil {
t.Fatalf("create gift channel: %v", err)
}
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
createdTarget, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: actor.ID, Title: "Gift Target Channel " + suffix, Megagroup: true, Date: now,
})
if err != nil {
t.Fatalf("create target gift channel: %v", err)
}
targetChannelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: createdTarget.Channel.ID}
gifts := NewStarGiftStore(pool)
baseDocumentID := (time.Now().UnixNano() & 0x7ffffffffffff000) + 500
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Channel Gift " + suffix, Stars: 50, ConvertStars: 20, Enabled: true, Limited: true,
AvailabilityTotal: 5, AvailabilityRemains: 5,
Document: collectibleTestDocument(baseDocumentID, "channel-gift.tgs"), Blob: collectibleTestBlob(baseDocumentID, "channel-gift"),
Animation: collectibleTestAnimation("channel-gift.tgs"), Actor: "integration", CommandID: "channel-gift-" + suffix,
})
if err != nil {
t.Fatalf("create channel gift catalog: %v", err)
}
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 5, SlugPrefix: "channel-life-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")}},
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: 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}},
Actor: "integration", CommandID: "channel-gift-pool-" + suffix,
}); err != nil {
t.Fatalf("publish channel gift pool: %v", err)
}
messages := NewMessageStore(pool)
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
StarsProceedsPermille: 900, TONProceedsPermille: 900,
}))
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500,
}))
channelPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
GiftID: entry.Gift.ID, CommandKey: "channel-purchase-" + suffix, Date: now + 1})
purchased, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq)
if err != nil || purchased.Saved.SavedID <= 0 || purchased.Balance.Balance != 9950 {
t.Fatalf("atomic channel purchase = %+v err %v", purchased, err)
}
var regularLogs int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(&regularLogs); err != nil || regularLogs != 1 {
t.Fatalf("channel purchase admin logs = %d err %v", regularLogs, err)
}
var channelPrice string
var channelPrepaidAmount any
if err := pool.QueryRow(ctx, `SELECT message #>> '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}'
FROM channel_admin_log_events WHERE channel_id=$1 AND event_type='send_message' ORDER BY id DESC LIMIT 1`, created.Channel.ID).
Scan(&channelPrice, &channelPrepaidAmount); err != nil || channelPrice != "100" || channelPrepaidAmount != nil {
t.Fatalf("channel ordinary action price=%q prepaid=%v err=%v", channelPrice, channelPrepaidAmount, err)
}
if replay, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq); err != nil || !replay.Duplicate {
t.Fatalf("channel purchase replay = %+v err %v", replay, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(&regularLogs); err != nil || regularLogs != 1 {
t.Fatalf("channel replay duplicated admin log count=%d err %v", regularLogs, err)
}
converted, err := lifecycle.ConvertStarGift(ctx, domain.StarGiftConvertRequest{ActorUserID: actor.ID,
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: purchased.Saved.SavedID}, Date: now + 2})
if err != nil || !converted.Saved.Converted || converted.OwnerBalance != 20 {
t.Fatalf("atomic channel conversion = %+v err %v", converted, err)
}
var channelBalance, conversionRows, conversionTxns int64
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 20 {
t.Fatalf("channel conversion balance = %d err %v", channelBalance, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_conversions WHERE saved_gift_id=$1`, purchased.Saved.ID).Scan(&conversionRows); err != nil || conversionRows != 1 {
t.Fatalf("channel conversion command rows = %d err %v", conversionRows, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_stars_transactions WHERE channel_id=$1 AND gift_id=$2`, created.Channel.ID, entry.Gift.ID).Scan(&conversionTxns); err != nil || conversionTxns != 1 {
t.Fatalf("channel conversion transactions = %d err %v", conversionTxns, err)
}
if balance, err := lifecycle.ChannelStarsBalance(ctx, created.Channel.ID); err != nil || balance != 20 {
t.Fatalf("channel stars balance projection = %d err %v", balance, err)
}
starsPage, err := lifecycle.ChannelStarsTransactions(ctx, created.Channel.ID, "", 20)
if err != nil || starsPage.Balance != 20 || len(starsPage.Transactions) != 1 ||
starsPage.Transactions[0].Amount != 20 || starsPage.Transactions[0].Reason != domain.StarsReasonGift {
t.Fatalf("channel stars transaction projection = %+v err %v", starsPage, err)
}
if _, err := lifecycle.ConvertStarGift(ctx, domain.StarGiftConvertRequest{ActorUserID: actor.ID,
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: purchased.Saved.SavedID}, Date: now + 3}); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
t.Fatalf("repeated channel conversion err = %v, want already converted", err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 20 {
t.Fatalf("channel balance after replay = %d err %v", channelBalance, err)
}
// A third party may prepay the upgrade entitlement of a channel-owned gift.
// The payer's personal Stars and the channel saved-gift entitlement commit
// together; the payment is also visible in channel Recent Actions.
channelPrepayTargetReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
GiftID: entry.Gift.ID, CommandKey: "channel-prepay-target-" + suffix, Date: now + 4})
channelPrepayTarget, err := lifecycle.PurchaseStarGift(ctx, channelPrepayTargetReq)
if err != nil || channelPrepayTarget.Saved.PrepaidUpgradeHash == "" {
t.Fatalf("channel prepay target purchase = %+v err %v", channelPrepayTarget, err)
}
prepayTarget, prepayPrice, err := lifecycle.PrepaidUpgradeTarget(ctx, channelPeer, channelPrepayTarget.Saved.PrepaidUpgradeHash)
if err != nil || prepayTarget.ID != channelPrepayTarget.Saved.ID || prepayPrice != 100 {
t.Fatalf("channel prepay target = %+v price=%d err=%v", prepayTarget, prepayPrice, err)
}
channelPrepayReq := domain.StarGiftPrepaidUpgradeRequest{
PayerUserID: actor.ID, Owner: channelPeer, Hash: channelPrepayTarget.Saved.PrepaidUpgradeHash,
ChargeStars: 100, FormID: 21006, CommandKey: "channel-prepay-" + suffix, Date: now + 4,
}
channelPrepay, err := lifecycle.PrepayStarGiftUpgrade(ctx, channelPrepayReq)
if err != nil || channelPrepay.Saved.PrepaidUpgradeStars != 100 || channelPrepay.Saved.PrepaidUpgradeHash != "" ||
channelPrepay.Send.RecipientMessage.OwnerUserID != actor.ID {
t.Fatalf("channel prepaid entitlement = %+v err %v", channelPrepay, err)
}
var prepayLogs int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 {
t.Fatalf("channel prepaid upgrade admin logs = %d err %v", prepayLogs, err)
}
channelPrepayReplay, err := lifecycle.PrepayStarGiftUpgrade(ctx, channelPrepayReq)
if err != nil || !channelPrepayReplay.Duplicate || channelPrepayReplay.Saved.ID != channelPrepay.Saved.ID {
t.Fatalf("channel prepaid entitlement replay = %+v err %v", channelPrepayReplay, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 {
t.Fatalf("channel prepaid upgrade replay logs = %d err %v", prepayLogs, err)
}
var ptsBeforeUpgrade int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsBeforeUpgrade); err != nil {
t.Fatal(err)
}
prepaidPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "channel-prepaid-purchase-" + suffix, Date: now + 4})
prepaidPurchase, err := lifecycle.PurchaseStarGift(ctx, prepaidPurchaseReq)
if err != nil || prepaidPurchase.Saved.PrepaidUpgradeStars != 100 || prepaidPurchase.Saved.SavedID <= 0 {
t.Fatalf("channel prepaid gift purchase = %+v err %v", prepaidPurchase, err)
}
upgradeReq := domain.StarGiftUpgradeRequest{UserID: actor.ID,
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: prepaidPurchase.Saved.SavedID}, RequirePrepaid: true,
KeepOriginalDetails: true, CommandKey: "channel-upgrade-" + suffix, Date: now + 5,
}
upgraded, err := upgrades.UpgradeStarGift(ctx, upgradeReq)
if err != nil || upgraded.Saved.Owner != channelPeer || upgraded.Unique.Owner != channelPeer ||
upgraded.Saved.SavedID != prepaidPurchase.Saved.SavedID || upgraded.Send.RecipientMessage.OwnerUserID != actor.ID {
t.Fatalf("channel prepaid upgrade = %+v err %v", upgraded, err)
}
action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if action == nil || action.FromUserID != domain.OfficialSystemUserID || action.Peer != channelPeer ||
action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 {
t.Fatalf("channel upgrade service action = %+v", action)
}
var ptsAfterUpgrade int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsAfterUpgrade); err != nil || ptsAfterUpgrade != ptsBeforeUpgrade {
t.Fatalf("channel pts after profile gift upgrade = %d want %d err %v", ptsAfterUpgrade, ptsBeforeUpgrade, err)
}
var upgradeLogs int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channel.ID).Scan(&upgradeLogs); err != nil || upgradeLogs != 1 {
t.Fatalf("channel upgrade admin logs = %d err %v", upgradeLogs, err)
}
replayedUpgrade, err := upgrades.UpgradeStarGift(ctx, upgradeReq)
if err != nil || !replayedUpgrade.Duplicate || replayedUpgrade.Unique.ID != upgraded.Unique.ID {
t.Fatalf("channel upgrade replay = %+v err %v", replayedUpgrade, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channel.ID).Scan(&upgradeLogs); err != nil || upgradeLogs != 1 {
t.Fatalf("channel upgrade replay admin logs = %d err %v", upgradeLogs, err)
}
dropped, err := lifecycle.DropStarGiftOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{
UserID: actor.ID, Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: upgraded.Saved.SavedID},
ChargeStars: 25, FormID: 21007, CommandKey: "channel-drop-details-" + suffix, Date: now + 6,
})
if err != nil || dropped.Saved.Owner != channelPeer || dropped.Unique.KeepOriginalDetails || dropped.Saved.DropOriginalDetailsStars != 0 {
t.Fatalf("channel drop original details = %+v err %v", dropped, err)
}
listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: actor.ID,
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: upgraded.Saved.SavedID},
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, Date: now + 6,
})
if err != nil || listed.ResellAmount == nil || listed.Owner != channelPeer {
t.Fatalf("list channel collectible = %+v err %v", listed, err)
}
if balance, err := lifecycle.TonBalance(ctx, actor.ID); err != nil || balance != 1_000_000 {
t.Fatalf("channel resale buyer local TON grant = %d err %v", balance, err)
}
resaleReq := domain.StarGiftResalePurchaseRequest{BuyerUserID: actor.ID, Slug: listed.Slug, To: targetChannelPeer,
Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, FormID: 21004,
CommandKey: "channel-to-channel-resale-" + suffix, Date: now + 7,
}
resold, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq)
if err != nil || resold.Unique.Owner != targetChannelPeer || resold.Saved.Owner != targetChannelPeer ||
resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 {
t.Fatalf("channel-to-channel local TON resale = %+v err %v", resold, err)
}
var channelTON, channelTONTxns, targetResaleLogs, commission int64
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 {
t.Fatalf("channel local TON proceeds = %d err %v", channelTON, err)
}
if balance, err := lifecycle.ChannelTonBalance(ctx, created.Channel.ID); err != nil || balance != 900 {
t.Fatalf("channel ton balance projection = %d err %v", balance, err)
}
tonPage, err := lifecycle.ChannelTonTransactions(ctx, created.Channel.ID, "", 20)
if err != nil || tonPage.Balance != 900 || len(tonPage.Transactions) != 1 ||
tonPage.Transactions[0].Amount != 900 || tonPage.Transactions[0].Reason != domain.StarsReasonGiftResale {
t.Fatalf("channel ton transaction projection = %+v err %v", tonPage, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_ton_transactions WHERE channel_id=$1 AND gift_id=$2`, created.Channel.ID, listed.ID).Scan(&channelTONTxns); err != nil || channelTONTxns != 1 {
t.Fatalf("channel local TON transactions = %d err %v", channelTONTxns, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`,
createdTarget.Channel.ID).Scan(&targetResaleLogs); err != nil || targetResaleLogs != 1 {
t.Fatalf("target channel resale admin logs = %d err %v", targetResaleLogs, err)
}
if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, resaleReq.CommandKey).Scan(&commission); err != nil || commission != 100 {
t.Fatalf("channel TON resale commission = %d err %v", commission, err)
}
resaleReplay, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq)
if err != nil || !resaleReplay.Duplicate || resaleReplay.Unique.ID != resold.Unique.ID {
t.Fatalf("channel resale replay = %+v err %v", resaleReplay, err)
}
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 {
t.Fatalf("channel TON proceeds after replay = %d err %v", channelTON, err)
}
var remainsBefore int
if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsBefore); err != nil {
t.Fatal(err)
}
balanceBefore, _ := NewStarsStore(pool).GetBalance(ctx, actor.ID)
invalidChannelReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID,
To: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID + 999999}, GiftID: entry.Gift.ID,
CommandKey: "invalid-channel-purchase-" + suffix, Date: now + 2})
_, err = lifecycle.PurchaseStarGift(ctx, invalidChannelReq)
if err == nil {
t.Fatal("purchase to missing channel unexpectedly succeeded")
}
var remainsAfter int
if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsAfter); err != nil || remainsAfter != remainsBefore {
t.Fatalf("inventory after rolled-back channel purchase = %d want %d err %v", remainsAfter, remainsBefore, err)
}
if balanceAfter, err := NewStarsStore(pool).GetBalance(ctx, actor.ID); err != nil || balanceAfter.Balance != balanceBefore.Balance {
t.Fatalf("balance after rolled-back channel purchase = %+v want %+v err %v", balanceAfter, balanceBefore, err)
}
auctionEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Channel Auction " + suffix, Stars: 100, Enabled: true, Limited: true, Auction: true,
AvailabilityTotal: 1, AvailabilityRemains: 1, GiftsPerRound: 1, AuctionStartDate: now - 10,
AuctionSlug: "channel-auction-" + suffix,
Document: collectibleTestDocument(baseDocumentID+100, "channel-auction.tgs"), Blob: collectibleTestBlob(baseDocumentID+100, "channel-auction"),
Animation: collectibleTestAnimation("channel-auction.tgs"), Actor: "integration", CommandID: "channel-auction-" + suffix,
})
if err != nil {
t.Fatalf("create channel auction: %v", err)
}
if _, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: actor.ID,
GiftID: auctionEntry.Gift.ID, Peer: channelPeer, BidAmount: 100, FormID: 22001, Date: now + 3,
}); err != nil {
t.Fatalf("bid channel auction: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE star_gift_auctions SET next_round_at=$2 WHERE gift_id=$1`, auctionEntry.Gift.ID, now+4); err != nil {
t.Fatal(err)
}
if err := lifecycle.SweepStarGiftLifecycle(ctx, now+4, 1000); err != nil {
t.Fatalf("settle channel auction: %v", err)
}
var awardSavedID int64
if err := pool.QueryRow(ctx, `SELECT saved_gift_id FROM star_gift_auction_acquired WHERE gift_id=$1`, auctionEntry.Gift.ID).Scan(&awardSavedID); err != nil || awardSavedID <= 0 {
t.Fatalf("channel auction saved id = %d err %v", awardSavedID, err)
}
var awardLogs int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%auction_acquired%'`, created.Channel.ID).Scan(&awardLogs); err != nil || awardLogs != 1 {
t.Fatalf("channel auction admin logs = %d err %v", awardLogs, err)
}
}
func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *StarGiftLifecycleStore,
req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest {
t.Helper()
var revisionID int64
if err := lifecycle.db.QueryRow(ctx, `SELECT active_revision_id FROM star_gift_catalog WHERE gift_id=$1`, req.GiftID).Scan(&revisionID); err != nil {
t.Fatalf("load active gift revision: %v", err)
}
gift, found, err := NewStarGiftStore(lifecycle.db).CatalogRevision(ctx, revisionID)
if err != nil || !found {
t.Fatalf("load gift revision %d: found=%v err=%v", revisionID, found, err)
}
req.RevisionID = gift.RevisionID
req.ChargeStars = gift.Stars
if req.IncludeUpgrade {
req.ChargeStars += gift.UpgradeStars
}
issued, err := lifecycle.IssueStarGiftPurchaseForm(ctx, domain.StarGiftPurchaseForm{
BuyerUserID: req.BuyerUserID, To: req.To, GiftID: req.GiftID, RevisionID: req.RevisionID,
IncludeUpgrade: req.IncludeUpgrade, HideName: req.HideName, Message: req.Message, ChargeStars: req.ChargeStars,
IssuedAt: req.Date, ExpiresAt: req.Date + 600,
})
if err != nil {
t.Fatalf("issue purchase form: %v", err)
}
req.FormID = issued.FormID
return req
}
func craftedSourceEditForUserAndGift(result domain.StarGiftCraftResult, userID, uniqueGiftID int64) domain.EditedMessageForUser {
for _, edit := range result.SourceEdits {
if edit.UserID != userID {
continue
}
action := starGiftUniqueActionFromEdit(edit)
if action != nil && action.Gift.ID == uniqueGiftID {
return edit
}
}
return domain.EditedMessageForUser{UserID: userID}
}
func starGiftUniqueActionFromEdit(edit domain.EditedMessageForUser) *domain.MessageStarGiftUniqueAction {
if edit.Message.Media == nil || edit.Message.Media.ServiceAction == nil {
return nil
}
return edit.Message.Media.ServiceAction.StarGiftUnique
}

View file

@ -0,0 +1,20 @@
package postgres
import (
"os"
"testing"
)
func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
}
status, err := MigrateAndStatus(dsn)
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 121 {
t.Fatalf("migration status = %+v, want clean version 121", status)
}
}

View file

@ -0,0 +1,98 @@
package postgres
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
func TestOfficialStarGiftBundleIsAtomicPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
store := NewStarGiftStore(pool)
baseID := time.Now().UnixNano() & 0x7ffffffffffff000
manifestSHA := make([]byte, 32)
for i := range manifestSHA {
manifestSHA[i] = 0x5a
}
attribute := func(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute {
value := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 918}
if kind == domain.StarGiftCollectibleBackdrop {
value.BackdropID = 0
value.CenterColor, value.EdgeColor, value.PatternColor, value.TextColor = 1, 2, 3, 4
return value
}
if kind == domain.StarGiftCollectiblePattern {
value.Document = collectibleTestPatternDocumentPtr(id, name+".tgs")
} else {
value.Document = collectibleTestDocumentPtr(id, name+".tgs")
}
value.Blob = collectibleTestBlobPtr(id, name)
value.Animation = collectibleTestAnimationPtr(name + ".tgs")
value.OfficialDocumentID = 5200000000000000000 + id%1000
return value
}
bundle := domain.StarGiftCatalogBundleWrite{
Catalog: domain.StarGiftCatalogWrite{
Title: "Official", Stars: 50, ConvertStars: 25, Enabled: true,
Document: collectibleTestDocument(baseID, "official.tgs"), Blob: collectibleTestBlob(baseID, "official"),
Animation: collectibleTestAnimation("official.tgs"), Actor: "integration", CommandID: "official-catalog-" + suffix,
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA,
OfficialSourceJSON: []byte(`{"id":5170145012310081615,"sold_out":true,"birthday":false}`),
},
Collectible: &domain.StarGiftCollectibleWrite{
UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "official-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleModel, baseID+1, "model")},
Patterns: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectiblePattern, baseID+2, "pattern")},
Backdrops: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop")},
Actor: "integration", CommandID: "official-pool-" + suffix,
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA,
},
}
result, err := store.CreateCatalogBundle(ctx, bundle)
if err != nil {
t.Fatalf("create official bundle: %v", err)
}
if result.Catalog.Gift.ID == 0 || result.Collectible == nil || result.Catalog.Gift.UpgradeStars != 100 {
t.Fatalf("bundle result = %+v", result)
}
var sourceID int64
var soldOut bool
if err := pool.QueryRow(ctx, `
SELECT official_gift_id, (official_source->>'sold_out')::boolean
FROM star_gift_catalog_revisions WHERE id=$1`, result.Catalog.Gift.RevisionID).Scan(&sourceID, &soldOut); err != nil {
t.Fatal(err)
}
if sourceID != 5170145012310081615 || !soldOut {
t.Fatalf("source id=%d sold_out=%v", sourceID, soldOut)
}
failing := bundle
failing.Catalog.CommandID = "official-rollback-" + suffix
failing.Catalog.Document = collectibleTestDocument(baseID+100, "rollback.tgs")
failing.Catalog.Blob = collectibleTestBlob(baseID+100, "rollback")
failing.Collectible = &domain.StarGiftCollectibleWrite{
UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "rollback-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectibleModel, baseID+101, "duplicate"),
attribute(domain.StarGiftCollectibleModel, baseID+102, "duplicate"),
},
Patterns: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectiblePattern, baseID+103, "pattern")},
Backdrops: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop")},
Actor: "integration", CommandID: "rollback-pool-" + suffix,
}
if _, err := store.CreateCatalogBundle(ctx, failing); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("failing bundle err=%v", err)
}
var rows int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM star_gift_catalog_revisions WHERE command_id=$1`, failing.Catalog.CommandID).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 0 {
t.Fatalf("failed bundle left %d catalog revisions", rows)
}
}

View file

@ -0,0 +1,344 @@
package postgres
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
func (s *StarGiftLifecycleStore) IssueStarGiftPurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) {
if s == nil || s.db == nil || form.FormID != 0 || form.BuyerUserID <= 0 || !validLifecyclePeer(form.To) ||
form.GiftID <= 0 || form.RevisionID <= 0 || form.ChargeStars <= 0 || form.IssuedAt <= 0 ||
form.ExpiresAt != form.IssuedAt+600 || len([]rune(form.Message)) > 128 {
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftFormPurposeInvalid
}
for attempt := 0; attempt < 8; attempt++ {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
return domain.StarGiftPurchaseForm{}, fmt.Errorf("generate star gift form id: %w", err)
}
form.FormID = int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
if form.FormID == 0 {
form.FormID = 1
}
_, err := s.db.Exec(ctx, `INSERT INTO star_gift_purchase_forms(buyer_user_id,form_id,gift_id,revision_id,
recipient_peer_type,recipient_peer_id,include_upgrade,hide_name,message,charge_stars,issued_at,expires_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, form.BuyerUserID, form.FormID, form.GiftID, form.RevisionID,
string(form.To.Type), form.To.ID, form.IncludeUpgrade, form.HideName, form.Message, form.ChargeStars, form.IssuedAt, form.ExpiresAt)
if err == nil {
return form, nil
}
if !isUniqueViolation(err) {
return domain.StarGiftPurchaseForm{}, err
}
}
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable
}
func (s *StarGiftLifecycleStore) ValidateStarGiftPurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error {
if s == nil || s.db == nil {
return domain.ErrStarGiftUnavailable
}
return validateStarGiftPurchaseForm(ctx, s.db, req, false)
}
func validateStarGiftPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarGiftPurchaseRequest, lock bool) error {
if req.BuyerUserID <= 0 || req.FormID == 0 || req.Date <= 0 {
return domain.ErrStarGiftFormExpired
}
query := `SELECT gift_id,revision_id,recipient_peer_type,recipient_peer_id,include_upgrade,hide_name,message,
charge_stars,issued_at,expires_at FROM star_gift_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2`
if lock {
query += ` FOR UPDATE`
}
var form domain.StarGiftPurchaseForm
var peerType string
err := db.QueryRow(ctx, query, req.BuyerUserID, req.FormID).Scan(&form.GiftID, &form.RevisionID, &peerType, &form.To.ID,
&form.IncludeUpgrade, &form.HideName, &form.Message, &form.ChargeStars, &form.IssuedAt, &form.ExpiresAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftFormExpired
}
if err != nil {
return err
}
form.FormID, form.BuyerUserID, form.To.Type = req.FormID, req.BuyerUserID, domain.PeerType(peerType)
if form.ExpiresAt < req.Date {
return domain.ErrStarGiftFormExpired
}
if form.To != req.To || form.GiftID != req.GiftID || form.IncludeUpgrade != req.IncludeUpgrade ||
form.HideName != req.HideName || form.Message != req.Message {
return domain.ErrStarGiftFormPurposeInvalid
}
if form.RevisionID != req.RevisionID || form.ChargeStars != req.ChargeStars {
return domain.ErrStarGiftFormAmountMismatch
}
return nil
}
func (s *StarGiftLifecycleStore) PurchaseStarGift(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
req.CommandKey = strings.TrimSpace(req.CommandKey)
if s == nil || s.db == nil || req.BuyerUserID <= 0 || !validLifecyclePeer(req.To) || req.GiftID <= 0 ||
req.FormID == 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.Date <= 0 || len([]rune(req.Message)) > 128 {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftInvalid
}
if replay, found, err := s.loadStarGiftPurchaseReplay(ctx, req, domain.SendPrivateTextResult{}); err != nil || found {
return replay, err
}
if err := s.ValidateStarGiftPurchaseForm(ctx, req); err != nil {
return domain.StarGiftPurchaseResult{}, err
}
if req.To.Type == domain.PeerTypeChannel {
return s.purchaseStarGiftToChannel(ctx, req)
}
if s.messages == nil {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable
}
fingerprint := starGiftPurchaseFingerprint(req)
messageReq := domain.SendPrivateTextRequest{SenderUserID: req.BuyerUserID, RecipientUserID: req.To.ID,
RandomID: lifecycleCommandRandomID("purchase", req.BuyerUserID, req.CommandKey), Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.BuyerUserID,
RecipientBlocked: req.RecipientBlocked, IdempotencyFingerprint: fingerprint[:],
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{Saved: true}}}}
var result domain.StarGiftPurchaseResult
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
if err := validateStarGiftPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
gift, saved, balance, err := s.prepareStarGiftPurchase(ctx, tx, req)
if err != nil {
return err
}
sticker := gift.Sticker
send.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{GiftID: gift.ID,
Stars: gift.Stars, ConvertStars: saved.ConvertStars, Title: gift.Title, Sticker: &sticker, Message: req.Message,
FromUserID: req.BuyerUserID, PeerUserID: req.To.ID, To: req.To, NameHidden: req.HideName, Saved: true,
CanUpgrade: gift.UpgradeStars > 0, PrepaidUpgrade: saved.PrepaidUpgradeStars > 0,
PrepaidUpgradeHash: saved.PrepaidUpgradeHash, UpgradePriceStars: gift.UpgradeStars,
UpgradeStars: saved.PrepaidUpgradeStars}}}
result.Gift, result.Saved, result.Balance = gift, saved, balance
return nil
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
msgID := sent.RecipientMessage.ID
if msgID <= 0 {
msgID = sent.SenderMessage.ID
}
result.Saved.MsgID = msgID
id, err := NewStarGiftStore(tx).Create(ctx, result.Saved)
if err != nil {
return err
}
result.Saved.ID = id
return s.insertStarGiftPurchaseCommand(ctx, tx, req, result.Saved.ID, result.Gift.Stars+result.Saved.PrepaidUpgradeStars, result.Balance.Balance)
},
}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, sent); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftPurchaseResult{}, err
}
result.Send, result.Duplicate = sent, sent.Duplicate
if sent.Duplicate {
replay, _, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, sent)
return replay, replayErr
}
return result, nil
}
func (s *StarGiftLifecycleStore) purchaseStarGiftToChannel(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
var result domain.StarGiftPurchaseResult
err := withTx(ctx, s.db, "purchase star gift for channel", func(tx pgx.Tx) error {
if err := validateStarGiftPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
gift, saved, balance, err := s.prepareStarGiftPurchase(ctx, tx, req)
if err != nil {
return err
}
id, err := NewStarGiftStore(tx).Create(ctx, saved)
if err != nil {
return err
}
saved.ID, saved.SavedID = id, id
sticker := gift.Sticker
action := domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID, Stars: gift.Stars, ConvertStars: saved.ConvertStars, Title: gift.Title,
Sticker: &sticker, Message: saved.Message, FromUserID: req.BuyerUserID, PeerChannelID: req.To.ID,
SavedID: id, NameHidden: saved.NameHidden, Saved: true, CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: saved.PrepaidUpgradeStars > 0, PrepaidUpgradeHash: saved.PrepaidUpgradeHash,
UpgradePriceStars: gift.UpgradeStars, UpgradeStars: saved.PrepaidUpgradeStars,
}}
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.BuyerUserID, id, req.Date, action); err != nil {
return err
}
if err := s.insertStarGiftPurchaseCommand(ctx, tx, req, id, gift.Stars+saved.PrepaidUpgradeStars, balance.Balance); err != nil {
return err
}
result = domain.StarGiftPurchaseResult{Gift: gift, Saved: saved, Balance: balance}
return nil
})
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, domain.SendPrivateTextResult{}); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftPurchaseResult{}, err
}
return result, nil
}
func (s *StarGiftLifecycleStore) prepareStarGiftPurchase(ctx context.Context, tx pgx.Tx, req domain.StarGiftPurchaseRequest) (domain.StarGift, domain.SavedStarGift, domain.StarsBalance, error) {
var revisionID int64
var enabled bool
var remains int
if err := tx.QueryRow(ctx, `SELECT active_revision_id,enabled,availability_remains FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, req.GiftID).
Scan(&revisionID, &enabled, &remains); err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftInvalid
}
gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, revisionID)
if err != nil || !found || !enabled || gift.ID != req.GiftID || gift.SoldOut || gift.Auction || gift.LockedUntilDate > req.Date ||
gift.Limited && remains <= 0 {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftInvalid
}
if gift.RevisionID != req.RevisionID {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftFormAmountMismatch
}
if gift.RequirePremium && !req.BuyerPremium {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrPremiumRequired
}
gift.AvailabilityRemains = remains
upgradePrice := int64(0)
prepayHash := ""
if gift.UpgradeStars > 0 || req.IncludeUpgrade {
revision, err := lockActiveCollectibleRevision(ctx, tx, gift.ID)
if err != nil || revision.Issued >= revision.SupplyTotal {
if req.IncludeUpgrade {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftCollectibleUnavailable
}
} else if req.IncludeUpgrade {
upgradePrice = revision.UpgradeStars
} else {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
prepayHash = base64.RawURLEncoding.EncodeToString(token[:])
}
}
if req.IncludeUpgrade && upgradePrice <= 0 {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftCollectibleUnavailable
}
if gift.Stars+upgradePrice != req.ChargeStars {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftFormAmountMismatch
}
var purchased int
if err := tx.QueryRow(ctx, `INSERT INTO star_gift_user_purchases(user_id,gift_id,purchased_count) VALUES($1,$2,1)
ON CONFLICT(user_id,gift_id) DO UPDATE SET purchased_count=star_gift_user_purchases.purchased_count+1,updated_at=now()
WHERE NOT $3 OR star_gift_user_purchases.purchased_count<$4 RETURNING purchased_count`, req.BuyerUserID, gift.ID,
gift.LimitedPerUser, gift.PerUserTotal).Scan(&purchased); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftUnavailable
}
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
if gift.Limited {
if tag, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET availability_remains=availability_remains-1,
first_sale_date=CASE WHEN first_sale_date=0 THEN $2 ELSE first_sale_date END,last_sale_date=$2,updated_at=now()
WHERE gift_id=$1 AND availability_remains>0`, gift.ID, req.Date); err != nil || tag.RowsAffected() != 1 {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftUnavailable
}
} else if _, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET first_sale_date=CASE WHEN first_sale_date=0 THEN $2 ELSE first_sale_date END,
last_sale_date=$2,updated_at=now() WHERE gift_id=$1`, gift.ID, req.Date); err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
charge := gift.Stars + upgradePrice
balance, err := s.debitLifecycleAmount(ctx, tx, req.BuyerUserID,
domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: charge}, domain.StarsReasonGift,
req.To, req.Date, "Star gift")
if err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
saved := domain.SavedStarGift{Owner: req.To, FromUserID: req.BuyerUserID, GiftID: gift.ID, RevisionID: gift.RevisionID,
Date: req.Date, NameHidden: req.HideName, ConvertStars: gift.ConvertStars, PrepaidUpgradeStars: upgradePrice,
PrepaidUpgradeHash: prepayHash, Message: req.Message}
return gift, saved, balance, nil
}
func (s *StarGiftLifecycleStore) insertStarGiftPurchaseCommand(ctx context.Context, tx pgx.Tx, req domain.StarGiftPurchaseRequest, savedID, charge, balance int64) error {
_, err := tx.Exec(ctx, `INSERT INTO star_gift_purchase_commands(buyer_user_id,command_key,gift_id,recipient_peer_type,
recipient_peer_id,saved_gift_id,form_id,charge_stars,balance_after,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.BuyerUserID, req.CommandKey, req.GiftID, string(req.To.Type), req.To.ID,
savedID, req.FormID, charge, balance, req.Date)
return err
}
func (s *StarGiftLifecycleStore) loadStarGiftPurchaseReplay(ctx context.Context, req domain.StarGiftPurchaseRequest, sent domain.SendPrivateTextResult) (domain.StarGiftPurchaseResult, bool, error) {
var giftID, recipientID, savedID, formID, charge, balance int64
var recipientType string
err := s.db.QueryRow(ctx, `SELECT gift_id,recipient_peer_type,recipient_peer_id,saved_gift_id,form_id,charge_stars,balance_after
FROM star_gift_purchase_commands WHERE buyer_user_id=$1 AND command_key=$2`, req.BuyerUserID, req.CommandKey).
Scan(&giftID, &recipientType, &recipientID, &savedID, &formID, &charge, &balance)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftPurchaseResult{}, false, nil
}
if err != nil {
return domain.StarGiftPurchaseResult{}, false, err
}
if giftID != req.GiftID || recipientType != string(req.To.Type) || recipientID != req.To.ID || formID != req.FormID || charge <= 0 {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
if saved.Owner != req.To || saved.GiftID != req.GiftID || saved.NameHidden != req.HideName || saved.Message != req.Message ||
(saved.PrepaidUpgradeStars > 0) != req.IncludeUpgrade {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
gift, found, err := NewStarGiftStore(s.db).CatalogRevision(ctx, saved.RevisionID)
if err != nil || !found {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
if req.To.Type == domain.PeerTypeUser && sent.SenderMessage.ID == 0 {
if s.messages == nil {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftUnavailable
}
fingerprint := starGiftPurchaseFingerprint(req)
replay, replayFound, replayErr := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{
SenderUserID: req.BuyerUserID, RecipientUserID: req.To.ID,
RandomID: lifecycleCommandRandomID("purchase", req.BuyerUserID, req.CommandKey), IdempotencyFingerprint: fingerprint[:],
})
if replayErr != nil || !replayFound {
if replayErr != nil {
return domain.StarGiftPurchaseResult{}, false, replayErr
}
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
sent = replay
}
return domain.StarGiftPurchaseResult{Gift: gift, Saved: saved, Balance: domain.StarsBalance{UserID: req.BuyerUserID, Balance: balance},
Send: sent, Duplicate: true}, true, nil
}
func starGiftPurchaseFingerprint(req domain.StarGiftPurchaseRequest) [32]byte {
return sha256.Sum256([]byte(fmt.Sprintf("telesrv:star-gift-purchase:v1:%d:%s:%d:%d:%t:%t:%s",
req.BuyerUserID, req.To.Type, req.To.ID, req.GiftID, req.IncludeUpgrade, req.HideName, req.Message)))
}

View file

@ -21,18 +21,38 @@ import (
// upgrades. It intentionally shares MessageStore's allocator and transaction
// machinery so Stars, issuance, the saved gift and durable updates commit once.
type StarGiftUpgradeStore struct {
db sqlcgen.DBTX
messages *MessageStore
db sqlcgen.DBTX
messages *MessageStore
lifecycle domain.StarGiftLifecyclePolicy
}
func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore) *StarGiftUpgradeStore {
return &StarGiftUpgradeStore{db: db, messages: messages}
type StarGiftUpgradeOption func(*StarGiftUpgradeStore)
func WithStarGiftLifecyclePolicy(policy domain.StarGiftLifecyclePolicy) StarGiftUpgradeOption {
return func(s *StarGiftUpgradeStore) {
if policy.Valid() {
s.lifecycle = policy
}
}
}
func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore, opts ...StarGiftUpgradeOption) *StarGiftUpgradeStore {
s := &StarGiftUpgradeStore{db: db, messages: messages, lifecycle: domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 250,
}}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
if s == nil || s.db == nil || s.messages == nil || req.UserID <= 0 || !req.Ref.Valid() ||
req.Ref.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
req.ChargeStars < 0 || req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 {
(req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) ||
(req.Ref.Owner.Type != domain.PeerTypeUser && req.Ref.Owner.Type != domain.PeerTypeChannel) ||
req.ChargeStars < 0 || (req.RequirePrepaid && (req.ChargeStars != 0 || req.FormID != 0)) ||
(!req.RequirePrepaid && (req.ChargeStars <= 0 || req.FormID == 0)) ||
req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 {
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
}
saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref)
@ -48,7 +68,11 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
"telesrv:star-gift-upgrade:v1:%s:%d:%d:%t:%d:%t",
commandKey, saved.ID, req.ChargeStars, req.RequirePrepaid, req.FormID, req.KeepOriginalDetails,
)))
randomID := starGiftUpgradeRandomID(saved.FromUserID, req.UserID, commandKey)
messageSenderID := saved.FromUserID
if saved.Owner.Type == domain.PeerTypeChannel {
messageSenderID = domain.OfficialSystemUserID
}
randomID := starGiftUpgradeRandomID(messageSenderID, req.UserID, commandKey)
placeholder := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
@ -57,7 +81,7 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
},
}
messageReq := domain.SendPrivateTextRequest{
SenderUserID: saved.FromUserID,
SenderUserID: messageSenderID,
RecipientUserID: req.UserID,
RandomID: randomID,
Media: placeholder,
@ -89,6 +113,19 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
if err != nil {
return err
}
var craftable bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM star_gift_collectible_models
WHERE collectible_revision_id=$1 AND crafted
)`, revision.ID).Scan(&craftable); err != nil {
return fmt.Errorf("load collectible craft capability: %w", err)
}
craftChancePermille := 0
canCraftAt := 0
if craftable {
craftChancePermille = s.lifecycle.CraftChancePermille
canCraftAt = starGiftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
}
if revision.Issued >= revision.SupplyTotal {
return domain.ErrStarGiftCollectibleSoldOut
}
@ -134,10 +171,12 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
INSERT INTO unique_star_gifts
(id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num,
owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id,
backdrop_attribute_id, keep_original_details)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
backdrop_attribute_id, keep_original_details, original_owner_peer_type, original_owner_peer_id,
craft_chance_permille, offer_min_stars)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
uniqueID, locked.GiftID, revision.ID, locked.ID, title, slug, num,
string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails); err != nil {
string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails,
string(locked.Owner.Type), locked.Owner.ID, craftChancePermille, s.lifecycle.OfferMinStars); err != nil {
return fmt.Errorf("insert unique star gift: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued+1 WHERE id=$1`, revision.ID); err != nil {
@ -145,14 +184,21 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
}
if _, err := tx.Exec(ctx, `
UPDATE peer_star_gifts
SET unique_gift_id=$2, prepaid_upgrade_stars=0, convert_stars=0
WHERE id=$1 AND unique_gift_id IS NULL AND NOT converted`, locked.ID, uniqueID); err != nil {
SET unique_gift_id=$2, prepaid_upgrade_stars=0, prepaid_upgrade_hash='', convert_stars=0,
transfer_stars=$3,can_export_at=$4,can_transfer_at=$5,can_resell_at=$6,
drop_original_details_stars=$7,can_craft_at=$8
WHERE id=$1 AND unique_gift_id IS NULL AND lifecycle_status='active'`, locked.ID, uniqueID,
s.lifecycle.TransferStars, starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds),
starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds), starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds),
s.lifecycle.DropOriginalDetailsStars, canCraftAt); err != nil {
return fmt.Errorf("upgrade saved star gift: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_upgrade_commands
(user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after)
VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance); err != nil {
(user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after,
charge_stars, require_prepaid, keep_original_details)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance,
req.ChargeStars, req.RequirePrepaid, req.KeepOriginalDetails); err != nil {
return fmt.Errorf("insert star gift upgrade command: %w", err)
}
@ -166,21 +212,20 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq
locked.UniqueGiftID = uniqueID
locked.PrepaidUpgradeStars = 0
locked.ConvertStars = 0
locked.TransferStars = s.lifecycle.TransferStars
locked.CanExportAt = starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds)
locked.CanTransferAt = starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds)
locked.CanResellAt = starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds)
locked.DropOriginalDetailsStars = s.lifecycle.DropOriginalDetailsStars
locked.CanCraftAt = canCraftAt
locked.Unique = &unique
result.Saved, result.Unique, result.Balance = locked, unique, balance
action := starGiftUpgradeUniqueAction(locked, unique, req, messageSenderID)
messageReq.Media = &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: unique, FromUserID: func() int64 {
if locked.NameHidden {
return 0
}
return locked.FromUserID
}(), Peer: locked.Owner, Upgrade: true, Saved: !locked.Unsaved,
PrepaidUpgrade: req.RequirePrepaid,
},
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: action,
},
}
return nil
@ -201,6 +246,40 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq
return fmt.Errorf("save star gift upgrade message id lost aggregate row")
}
result.Saved.UpgradeMsgID = ownerMessageID
if result.Saved.Owner.Type == domain.PeerTypeUser {
edits, err := s.markPrivateStarGiftSourceUpgradedTx(ctx, tx, req, result.Saved, sent)
if err != nil {
return err
}
result.SourceEdits = edits
ownerEditPts := 0
for _, edit := range edits {
if edit.UserID == req.UserID {
ownerEditPts = edit.Event.Pts
break
}
}
if ownerEditPts <= 0 {
return fmt.Errorf("upgrade source edit missing owner event")
}
tag, err := tx.Exec(ctx, `
UPDATE star_gift_upgrade_commands SET source_edit_pts=$3
WHERE user_id=$1 AND command_key=$2`, req.UserID, commandKey, ownerEditPts)
if err != nil {
return fmt.Errorf("save star gift source edit pts: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("save star gift source edit pts lost command row")
}
} else {
action := starGiftUpgradeUniqueAction(result.Saved, result.Unique, req, messageSenderID)
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, result.Saved.Owner.ID,
req.UserID, result.Saved.SavedID, req.Date, domain.ChannelMessageAction{
Type: domain.ChannelActionStarGiftUnique, StarGiftUnique: action,
}); err != nil {
return fmt.Errorf("append channel star gift upgrade admin log: %w", err)
}
}
return nil
},
}
@ -216,11 +295,186 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq
return result, nil
}
func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.UniqueStarGift, req domain.StarGiftUpgradeRequest, messageSenderID int64) *domain.MessageStarGiftUniqueAction {
fromUserID := saved.FromUserID
if saved.NameHidden {
fromUserID = 0
}
if saved.Owner.Type == domain.PeerTypeChannel {
// TDesktop recognizes a channel-owned upgrade from the official service
// peer plus action.peer=channel and action.saved_id.
fromUserID = messageSenderID
}
savedID := saved.SavedID
if saved.Owner.Type == domain.PeerTypeUser {
// For user-owned gifts messageActionStarGiftUnique.saved_id is the
// stable source gift message id. TDesktop uses this back-reference as
// inputSavedStarGiftUser.msg_id for crafting and later lifecycle RPCs.
savedID = int64(saved.MsgID)
}
return &domain.MessageStarGiftUniqueAction{
Gift: unique, FromUserID: fromUserID, Peer: saved.Owner, SavedID: savedID,
Upgrade: true, Saved: !saved.Unsaved, PrepaidUpgrade: req.RequirePrepaid,
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars,
CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt,
}
}
// markPrivateStarGiftSourceUpgradedTx rewrites both visible copies of the
// original gift service message in the same transaction that creates the
// unique gift message. upgrade_msg_id is box-local, so each owner projection
// must point at that owner's copy of the new service message. Every rewrite is
// a durable edit_message event with its own pts and outbox row.
func (s *StarGiftUpgradeStore) markPrivateStarGiftSourceUpgradedTx(
ctx context.Context,
tx pgx.Tx,
req domain.StarGiftUpgradeRequest,
saved domain.SavedStarGift,
sent domain.SendPrivateTextResult,
) ([]domain.EditedMessageForUser, error) {
if saved.Owner.Type != domain.PeerTypeUser || saved.Owner.ID != req.UserID || saved.MsgID <= 0 {
return nil, domain.ErrStarGiftCollectibleInvalid
}
q := sqlcgen.New(tx)
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
OwnerUserID: req.UserID,
BoxID: int32(saved.MsgID),
PeerType: string(domain.PeerTypeUser),
PeerID: saved.FromUserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrStarGiftCollectibleInvalid
}
return nil, fmt.Errorf("lock star gift source message: %w", err)
}
boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
OwnerUserIds: privateMessageOwnerIDs(req.UserID, saved.FromUserID),
MessageSenderID: target.MessageSenderID,
PrivateMessageID: target.PrivateMessageID,
})
if err != nil {
return nil, fmt.Errorf("list star gift source message boxes: %w", err)
}
if len(boxes) == 0 {
return nil, domain.ErrStarGiftCollectibleInvalid
}
upgradeMessageIDs := make(map[int64]int, 2)
if sent.SenderMessage.OwnerUserID > 0 && sent.SenderMessage.ID > 0 {
upgradeMessageIDs[sent.SenderMessage.OwnerUserID] = sent.SenderMessage.ID
}
if sent.RecipientMessage.OwnerUserID > 0 && sent.RecipientMessage.ID > 0 {
upgradeMessageIDs[sent.RecipientMessage.OwnerUserID] = sent.RecipientMessage.ID
}
edits := make([]domain.EditedMessageForUser, 0, len(boxes))
var privateMediaJSON []byte
for _, box := range boxes {
upgradeMessageID := upgradeMessageIDs[box.OwnerUserID]
if upgradeMessageID <= 0 {
return nil, fmt.Errorf("upgrade service message missing box for user %d", box.OwnerUserID)
}
media, err := decodeMessageMedia(box.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode star gift source media: %w", err)
}
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGift || media.ServiceAction.StarGift == nil {
return nil, fmt.Errorf("star gift source message %d has invalid media", box.BoxID)
}
action := media.ServiceAction.StarGift
if action.UpgradeMsgID != 0 && action.UpgradeMsgID != upgradeMessageID {
return nil, fmt.Errorf("star gift source message %d has conflicting upgrade message %d", box.BoxID, action.UpgradeMsgID)
}
action.UpgradeMsgID = upgradeMessageID
action.CanUpgrade = false
mediaJSON, err := encodeMessageMedia(media)
if err != nil {
return nil, fmt.Errorf("encode upgraded star gift source media: %w", err)
}
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
if err != nil {
return nil, fmt.Errorf("allocate star gift source edit pts: %w", err)
}
tag, err := tx.Exec(ctx, `
UPDATE message_boxes SET media=$3, pts=$4
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts))
if err != nil {
return nil, fmt.Errorf("update star gift source message box: %w", err)
}
if tag.RowsAffected() != 1 {
return nil, fmt.Errorf("update star gift source message box lost row")
}
msg, err := messageFromVisibleBoxRow(box)
if err != nil {
return nil, err
}
msg.Media = media
msg.Pts = pts
if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil {
return nil, err
}
event := domain.UpdateEvent{
UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage,
Pts: pts, PtsCount: 1, Date: req.Date, Message: msg,
}
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
return nil, fmt.Errorf("append star gift source edit event: %w", err)
}
dispatchAuthKeyID := [8]byte{}
dispatchSessionID := int64(0)
if msg.OwnerUserID == req.UserID {
dispatchAuthKeyID = req.OriginAuthKeyID
dispatchSessionID = req.OriginSessionID
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage),
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), ExcludeSessionID: dispatchSessionID,
}); err != nil {
return nil, fmt.Errorf("enqueue star gift source edit: %w", err)
}
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
privateMediaJSON = mediaJSON
}
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
}
if len(privateMediaJSON) == 0 {
return nil, fmt.Errorf("upgrade source message missing private media projection")
}
if _, err := tx.Exec(ctx, `
UPDATE private_messages SET media=$3
WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil {
return nil, fmt.Errorf("update star gift source private message: %w", err)
}
return edits, nil
}
func starGiftReadyAt(date, delaySeconds int) int {
if date <= 0 || delaySeconds <= 0 {
return 0
}
const maxProtocolDate = int(1<<31 - 1)
if delaySeconds > maxProtocolDate-date {
return maxProtocolDate
}
return date + delaySeconds
}
func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
where, args := savedStarGiftRefWhere(ref)
return lockSavedStarGiftWhere(ctx, tx, where, args...)
}
func lockSavedStarGiftByID(ctx context.Context, tx pgx.Tx, savedID int64) (domain.SavedStarGift, error) {
return lockSavedStarGiftWhere(ctx, tx, "p.id = $1", savedID)
}
func lockSavedStarGiftWhere(ctx context.Context, tx pgx.Tx, where string, args ...any) (domain.SavedStarGift, error) {
row := tx.QueryRow(ctx, `
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num,
p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at,
p.drop_original_details_stars, p.can_craft_at,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
@ -283,7 +537,13 @@ func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64,
}
func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID int64) (int64, error) {
rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, table), revisionID)
extra := ""
if table == "star_gift_collectible_models" {
extra = " AND NOT crafted"
}
rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s
WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0%s
ORDER BY sort_order, id`, table, extra), revisionID)
if err != nil {
return 0, fmt.Errorf("list collectible attributes for issuance: %w", err)
}
@ -305,7 +565,7 @@ func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, re
if err := rows.Err(); err != nil {
return 0, err
}
if len(items) == 0 || total != 1000 {
if len(items) == 0 || total <= 0 {
return 0, domain.ErrStarGiftCollectibleInvalid
}
draw, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
@ -346,20 +606,94 @@ func (s *StarGiftUpgradeStore) loadUpgradeReplay(ctx context.Context, req domain
}
return domain.StarGiftUpgradeResult{}, err
}
var commandUniqueID int64
var balanceAfter int64
if err := s.db.QueryRow(ctx, `SELECT unique_gift_id, balance_after FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&commandUniqueID, &balanceAfter); err != nil {
receipt, found, err := s.StarGiftUpgradeReceipt(ctx, req.UserID, req.CommandKey)
if err != nil {
return domain.StarGiftUpgradeResult{}, fmt.Errorf("load star gift upgrade replay: %w", err)
}
if commandUniqueID != unique.ID || saved.ID != original.ID {
if !found || receipt.UniqueGiftID != unique.ID || receipt.SourceSavedGiftID != saved.ID || saved.ID != original.ID ||
receipt.FormID != req.FormID || receipt.ChargeStars != req.ChargeStars || receipt.RequirePrepaid != req.RequirePrepaid ||
receipt.KeepOriginalDetails != req.KeepOriginalDetails {
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
}
uniqueCopy := unique
saved.Unique = &uniqueCopy
sourceEdits, err := s.loadUpgradeSourceReplay(ctx, req, saved, receipt.SourceEditPts)
if err != nil {
return domain.StarGiftUpgradeResult{}, err
}
return domain.StarGiftUpgradeResult{
Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: balanceAfter},
Send: sent, Duplicate: true,
Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: receipt.BalanceAfter},
Send: sent, SourceEdits: sourceEdits, Duplicate: true,
}, nil
}
func (s *StarGiftUpgradeStore) loadUpgradeSourceReplay(ctx context.Context, req domain.StarGiftUpgradeRequest, saved domain.SavedStarGift, pts int) ([]domain.EditedMessageForUser, error) {
if saved.Owner.Type != domain.PeerTypeUser {
return nil, nil
}
if pts <= 0 || saved.MsgID <= 0 {
return nil, domain.ErrStarGiftCollectibleInvalid
}
var privateMessageID, messageSenderID int64
err := s.db.QueryRow(ctx, `
SELECT private_message_id,message_sender_id FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND peer_type='user' AND peer_id=$3 AND NOT deleted`,
req.UserID, saved.MsgID, saved.FromUserID).Scan(&privateMessageID, &messageSenderID)
if errors.Is(err, pgx.ErrNoRows) {
// A later delete event is authoritative; replaying the old edit here
// would transiently resurrect the source message.
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("load star gift source replay message: %w", err)
}
boxes, err := sqlcgen.New(s.db).ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
OwnerUserIds: []int64{req.UserID}, MessageSenderID: messageSenderID, PrivateMessageID: privateMessageID,
})
if err != nil {
return nil, fmt.Errorf("load star gift source replay box: %w", err)
}
if len(boxes) != 1 || int(boxes[0].BoxID) != saved.MsgID {
return nil, domain.ErrStarGiftCollectibleInvalid
}
var eventDate int
err = s.db.QueryRow(ctx, `
SELECT date FROM user_update_events
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`,
req.UserID, pts, saved.MsgID).Scan(&eventDate)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrStarGiftCollectibleInvalid
}
return nil, fmt.Errorf("load star gift source replay event: %w", err)
}
msg, err := messageFromVisibleBoxRow(boxes[0])
if err != nil {
return nil, err
}
msg.Pts = pts
event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage, Pts: pts, PtsCount: 1, Date: eventDate, Message: msg}
return []domain.EditedMessageForUser{{UserID: req.UserID, Message: msg, Event: event}}, nil
}
func (s *StarGiftUpgradeStore) StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) {
commandKey = strings.TrimSpace(commandKey)
if s == nil || s.db == nil || userID <= 0 || commandKey == "" || len(commandKey) > 256 {
return domain.StarGiftUpgradeReceipt{}, false, nil
}
receipt := domain.StarGiftUpgradeReceipt{UserID: userID}
err := s.db.QueryRow(ctx, `
SELECT source_saved_gift_id,form_id,unique_gift_id,charge_stars,balance_after,source_edit_pts,require_prepaid,keep_original_details
FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, userID, commandKey).Scan(
&receipt.SourceSavedGiftID, &receipt.FormID, &receipt.UniqueGiftID, &receipt.ChargeStars,
&receipt.BalanceAfter, &receipt.SourceEditPts, &receipt.RequirePrepaid, &receipt.KeepOriginalDetails)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftUpgradeReceipt{}, false, nil
}
if err != nil {
return domain.StarGiftUpgradeReceipt{}, false, err
}
return receipt, true, nil
}
var _ store.StarGiftUpgradeStore = (*StarGiftUpgradeStore)(nil)

View file

@ -211,31 +211,36 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer
if err != nil {
return err
}
emojiStatusPayload, err := encodeEventEmojiStatus(event.EmojiStatus)
if err != nil {
return err
}
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
UserID: userID,
Pts: int32(event.Pts),
PtsCount: int32(event.PtsCount),
Date: int32(event.Date),
EventType: string(event.Type),
EventBool: event.Bool,
EventPhone: event.Phone,
EventPeers: peers,
PeerSettings: settings,
MessageIds: messageIDs,
DialogFilter: dialogFilter,
FilterOrder: filterOrder,
FolderPeers: folderPeers,
StoryPayload: storyPayload,
ReactionPayload: reactionPayload,
MaxID: pgInt32NonNegative(event.MaxID),
StillUnreadCount: int32(event.StillUnreadCount),
ChannelPts: int32(event.ChannelPts),
FilterID: pgInt32NonNegative(event.FilterID),
TagsEnabled: event.TagsEnabled,
FolderID: pgInt32NonNegative(event.FolderID),
MessageBoxID: messageID,
PeerType: peerType,
PeerID: peerID,
UserID: userID,
Pts: int32(event.Pts),
PtsCount: int32(event.PtsCount),
Date: int32(event.Date),
EventType: string(event.Type),
EventBool: event.Bool,
EventPhone: event.Phone,
EventPeers: peers,
PeerSettings: settings,
MessageIds: messageIDs,
DialogFilter: dialogFilter,
FilterOrder: filterOrder,
FolderPeers: folderPeers,
StoryPayload: storyPayload,
ReactionPayload: reactionPayload,
EmojiStatusPayload: emojiStatusPayload,
MaxID: pgInt32NonNegative(event.MaxID),
StillUnreadCount: int32(event.StillUnreadCount),
ChannelPts: int32(event.ChannelPts),
FilterID: pgInt32NonNegative(event.FilterID),
TagsEnabled: event.TagsEnabled,
FolderID: pgInt32NonNegative(event.FolderID),
MessageBoxID: messageID,
PeerType: peerType,
PeerID: peerID,
}); err != nil {
return err
}
@ -385,6 +390,10 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
if err != nil {
return nil, fmt.Errorf("decode reaction payload: %w", err)
}
emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson)
if err != nil {
return nil, fmt.Errorf("decode emoji status payload: %w", err)
}
media, err := decodeMessageMedia(row.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode message media: %w", err)
@ -420,6 +429,7 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
TagsEnabled: row.TagsEnabled,
FolderID: int(row.FolderID),
Reaction: reaction,
EmojiStatus: emojiStatus,
Message: domain.Message{
ID: int(row.MessageID),
UID: row.PrivateMessageID,
@ -579,6 +589,10 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
if err != nil {
return nil, fmt.Errorf("decode reaction payload: %w", err)
}
emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson)
if err != nil {
return nil, fmt.Errorf("decode emoji status payload: %w", err)
}
media, err := decodeMessageMedia(row.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode message media: %w", err)
@ -614,6 +628,7 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
TagsEnabled: row.TagsEnabled,
FolderID: int(row.FolderID),
Reaction: reaction,
EmojiStatus: emojiStatus,
Message: domain.Message{
ID: int(row.MessageID),
UID: row.PrivateMessageID,
@ -979,6 +994,31 @@ func decodeEventReaction(raw string) (*domain.MessageReaction, error) {
return decodeStoryReaction(raw)
}
func encodeEventEmojiStatus(status domain.UserEmojiStatus) ([]byte, error) {
if !status.Valid() {
return nil, domain.ErrStarGiftCollectibleInvalid
}
raw, err := json.Marshal(status)
if err != nil {
return nil, fmt.Errorf("marshal event emoji status: %w", err)
}
return raw, nil
}
func decodeEventEmojiStatus(raw string) (domain.UserEmojiStatus, error) {
if raw == "" || raw == "{}" || raw == "null" {
return domain.UserEmojiStatus{}, nil
}
var status domain.UserEmojiStatus
if err := json.Unmarshal([]byte(raw), &status); err != nil {
return domain.UserEmojiStatus{}, err
}
if !status.Valid() {
return domain.UserEmojiStatus{}, domain.ErrStarGiftCollectibleInvalid
}
return status, nil
}
type peerSettingsJSON struct {
AddContact bool `json:"add_contact,omitempty"`
BlockContact bool `json:"block_contact,omitempty"`

View file

@ -2,6 +2,7 @@ package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@ -152,27 +153,30 @@ func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phon
Results: make([]domain.User, 0, len(rows)),
}
for _, row := range rows {
collectible := mustDecodeEmojiStatusCollectible(row.EmojiStatusCollectibleID, row.EmojiStatusCollectible)
u := domain.User{
ID: row.ID,
AccessHash: row.AccessHash,
Phone: row.Phone,
FirstName: row.FirstName,
LastName: row.LastName,
About: row.About,
Username: row.Username,
CountryCode: row.CountryCode,
Verified: row.Verified,
Support: row.Support,
Bot: row.IsBot,
BotInfoVersion: int(row.BotInfoVersion),
PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt),
EmojiStatusDocumentID: row.EmojiStatusDocumentID,
EmojiStatusUntil: int(row.EmojiStatusUntil),
Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID),
ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID),
LastSeenAt: int(row.LastSeenAt),
Contact: row.Contact,
Mutual: row.Mutual,
ID: row.ID,
AccessHash: row.AccessHash,
Phone: row.Phone,
FirstName: row.FirstName,
LastName: row.LastName,
About: row.About,
Username: row.Username,
CountryCode: row.CountryCode,
Verified: row.Verified,
Support: row.Support,
Bot: row.IsBot,
BotInfoVersion: int(row.BotInfoVersion),
PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt),
EmojiStatusDocumentID: row.EmojiStatusDocumentID,
EmojiStatusUntil: int(row.EmojiStatusUntil),
EmojiStatusCollectible: collectible,
Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID),
ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID),
LinkedCommunityID: row.LinkedCommunityID,
LastSeenAt: int(row.LastSeenAt),
Contact: row.Contact,
Mutual: row.Mutual,
}
if row.Contact {
out.MyResults = append(out.MyResults, u)
@ -218,7 +222,7 @@ func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username s
}()
qtx := s.q.WithTx(tx)
var lockedUserID int64
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedUserID); err != nil {
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&lockedUserID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUsernameNotOccupied
}
@ -355,22 +359,110 @@ func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit in
return out, nil
}
// UpdateEmojiStatus 更新用户自定义 emoji statusdocumentID=0 表示清除)。
func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) {
row, err := s.q.UpdateUserEmojiStatus(ctx, sqlcgen.UpdateUserEmojiStatusParams{
ID: userID,
EmojiStatusDocumentID: documentID,
EmojiStatusUntil: int64(until),
})
// UpdateEmojiStatus atomically replaces the complete emoji-status snapshot.
func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status)
if err != nil {
return domain.User{}, err
}
params := sqlcgen.UpdateUserEmojiStatusParams{
ID: userID,
EmojiStatusDocumentID: status.DocumentID,
EmojiStatusUntil: int64(status.Until),
EmojiStatusCollectibleID: collectibleID,
EmojiStatusCollectible: collectibleJSON,
}
var row sqlcgen.User
if status.Collectible.Empty() {
row, err = updateEmojiStatusRow(ctx, s.db, s.q, userID, status, params)
} else {
// Serialize selection against transfer/export/burn. RPC-level ownership
// checks are advisory; this lock is the write-boundary invariant that
// prevents a concurrent lifecycle commit from leaving a non-owned gift
// installed after its invalidation trigger already ran.
err = withTx(ctx, s.db, "update collectible emoji status", func(tx pgx.Tx) error {
row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params)
return err
})
}
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
return domain.User{}, err
}
return domain.User{}, fmt.Errorf("update user emoji status: %w", err)
}
return userFromModel(row), nil
}
// UpdateEmojiStatusWithEvent commits the user snapshot, allocated pts event
// and dispatch outbox row as one aggregate transaction. This is the production
// boundary used by account.updateEmojiStatus; no success can expose a users
// row whose change is absent from updates.getDifference.
func (s *UserStore) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, error) {
collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status)
if err != nil {
return domain.User{}, domain.UpdateEvent{}, err
}
if event.Type != domain.UpdateEventUserEmojiStatus || event.EmojiStatus != status ||
event.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
return domain.User{}, domain.UpdateEvent{}, domain.ErrStarGiftCollectibleInvalid
}
params := sqlcgen.UpdateUserEmojiStatusParams{
ID: userID,
EmojiStatusDocumentID: status.DocumentID,
EmojiStatusUntil: int64(status.Until),
EmojiStatusCollectibleID: collectibleID,
EmojiStatusCollectible: collectibleJSON,
}
var row sqlcgen.User
err = withTx(ctx, s.db, "update emoji status with event", func(tx pgx.Tx) error {
row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params)
if err != nil {
return err
}
event, err = NewUpdateEventStore(tx).AppendAllocatedWithDispatch(
ctx, userID, event, excludeAuthKeyID, excludeSessionID,
)
return err
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.UpdateEvent{}, domain.ErrUserNotFound
}
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
return domain.User{}, domain.UpdateEvent{}, err
}
return domain.User{}, domain.UpdateEvent{}, fmt.Errorf("update user emoji status with event: %w", err)
}
return userFromModel(row), event, nil
}
func updateEmojiStatusRow(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, userID int64, status domain.UserEmojiStatus, params sqlcgen.UpdateUserEmojiStatusParams) (sqlcgen.User, error) {
if !status.Collectible.Empty() {
var lockedID int64
if err := db.QueryRow(ctx, `
SELECT id FROM unique_star_gifts WHERE id=$1 FOR UPDATE`, status.Collectible.CollectibleID).Scan(&lockedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return sqlcgen.User{}, domain.ErrStarGiftCollectibleInvalid
}
return sqlcgen.User{}, err
}
gift, found, err := NewStarGiftStore(db).UniqueByID(ctx, lockedID)
if err != nil {
return sqlcgen.User{}, err
}
expected, valid := domain.CollectibleEmojiStatus(gift)
if !found || !valid || gift.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) ||
gift.Burned || gift.OwnerAddress != "" || expected != status.Collectible {
return sqlcgen.User{}, domain.ErrStarGiftCollectibleInvalid
}
}
return q.UpdateUserEmojiStatus(ctx, params)
}
// UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。
func (s *UserStore) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
row, err := s.q.UpdateUserBirthday(ctx, sqlcgen.UpdateUserBirthdayParams{
@ -463,29 +555,74 @@ func escapeLike(s string) string {
}
func userFromModel(r sqlcgen.User) domain.User {
return domain.User{
ID: r.ID,
AccessHash: r.AccessHash,
Phone: r.Phone,
SignupEmail: r.SignupEmail,
FirstName: r.FirstName,
LastName: r.LastName,
About: r.About,
Username: r.Username,
CountryCode: r.CountryCode,
Verified: r.Verified,
Support: r.Support,
Bot: r.IsBot,
BotInfoVersion: int(r.BotInfoVersion),
PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt),
EmojiStatusDocumentID: r.EmojiStatusDocumentID,
EmojiStatusUntil: int(r.EmojiStatusUntil),
Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)},
PersonalChannelID: r.PersonalChannelID,
Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID),
ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID),
LastSeenAt: int(r.LastSeenAt),
collectible := mustDecodeEmojiStatusCollectible(r.EmojiStatusCollectibleID, r.EmojiStatusCollectible)
u := domain.User{
ID: r.ID,
AccessHash: r.AccessHash,
Phone: r.Phone,
SignupEmail: r.SignupEmail,
FirstName: r.FirstName,
LastName: r.LastName,
About: r.About,
Username: r.Username,
CountryCode: r.CountryCode,
Verified: r.Verified,
Support: r.Support,
Bot: r.IsBot,
BotInfoVersion: int(r.BotInfoVersion),
PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt),
EmojiStatusDocumentID: r.EmojiStatusDocumentID,
EmojiStatusUntil: int(r.EmojiStatusUntil),
EmojiStatusCollectible: collectible,
Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)},
PersonalChannelID: r.PersonalChannelID,
LinkedCommunityID: r.LinkedCommunityID,
Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID),
ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID),
LastSeenAt: int(r.LastSeenAt),
Deleted: r.DeletedAt.Valid,
DeletionSource: domain.AccountDeletionSource(r.DeletionSource),
DeletionReason: r.DeletionReason,
CreatedAt: r.CreatedAt.Time,
AccountDeleteAt: r.AccountDeleteAt.Time,
}
if r.DeletedAt.Valid {
u.DeletedAt = r.DeletedAt.Time.Unix()
return u.DeletedTombstone()
}
return u
}
func encodeEmojiStatusCollectible(status domain.UserEmojiStatus) ([]byte, *int64, error) {
if !status.Valid() {
return nil, nil, domain.ErrStarGiftCollectibleInvalid
}
if status.Collectible.Empty() {
return []byte(`{}`), nil, nil
}
raw, err := json.Marshal(status.Collectible)
if err != nil {
return nil, nil, fmt.Errorf("encode collectible emoji status: %w", err)
}
id := status.Collectible.CollectibleID
return raw, &id, nil
}
func mustDecodeEmojiStatusCollectible(id *int64, raw []byte) domain.EmojiStatusCollectible {
var collectible domain.EmojiStatusCollectible
if err := json.Unmarshal(raw, &collectible); err != nil {
panic(fmt.Sprintf("invalid users.emoji_status_collectible JSON: %v", err))
}
if id == nil {
if !collectible.Empty() {
panic("users emoji-status invariant: snapshot exists without collectible id")
}
return domain.EmojiStatusCollectible{}
}
if !collectible.Valid() || collectible.CollectibleID != *id {
panic("users emoji-status invariant: incomplete or mismatched collectible snapshot")
}
return collectible
}
func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor {