feat: sync monoforum and collectible emoji status

Sync telesrv bd15657 (feat(account): implement collectible emoji status).

Skipped telesrv docs changes per public sync rules.
This commit is contained in:
A 2026-07-19 20:37:10 +08:00
parent edb7057757
commit 0c99ae0a9d
91 changed files with 4061 additions and 693 deletions

View file

@ -180,6 +180,7 @@ 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,

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
}

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

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

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

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

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

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

@ -656,22 +656,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

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

@ -37,6 +37,8 @@ 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,
@ -86,6 +88,8 @@ 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,
@ -165,6 +169,8 @@ 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 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, deleted_at, deletion_source, deletion_reason, account_delete_at
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
`
type InsertBotUserParams struct {
@ -213,6 +213,8 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
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

@ -2144,6 +2144,8 @@ type User struct {
DeletionSource string
DeletionReason string
AccountDeleteAt pgtype.Timestamptz
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
}
type UserBusinessProfile struct {
@ -2219,33 +2221,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 {

View file

@ -14,7 +14,7 @@ import (
const createUser = `-- name: CreateUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
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
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
`
type CreateUserParams struct {
@ -72,12 +72,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at 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 FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
@ -117,12 +119,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
const getUserByPhone = `-- name: GetUserByPhone :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE phone = $1 AND deleted_at IS NULL
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE phone = $1 AND deleted_at IS NULL
`
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
@ -162,12 +166,14 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible 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) {
@ -207,12 +213,14 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
const getUsersByIDs = `-- name: GetUsersByIDs :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
FROM users
WHERE id = ANY($1::bigint[])
ORDER BY id
@ -261,6 +269,8 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
); err != nil {
return nil, err
}
@ -273,7 +283,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
}
const getUsersByPhones = `-- name: GetUsersByPhones :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
FROM users
WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
ORDER BY id
@ -322,6 +332,8 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
); err != nil {
return nil, err
}
@ -351,6 +363,8 @@ 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,
@ -400,6 +414,8 @@ 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,
@ -438,6 +454,8 @@ type SearchUsersRow struct {
PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64
EmojiStatusUntil int64
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
ColorSet bool
Color int32
ColorBackgroundEmojiID int64
@ -480,6 +498,8 @@ 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,
@ -505,7 +525,7 @@ UPDATE users
SET premium_expires_at = $1::timestamptz,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
`
type SetUserPremiumUntilParams struct {
@ -550,6 +570,8 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
@ -559,7 +581,7 @@ UPDATE users
SET verified = $1::boolean,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
`
type SetUserVerifiedParams struct {
@ -604,6 +626,8 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
@ -620,7 +644,7 @@ WHERE id IN (
ORDER BY premium_expires_at
LIMIT $2::int
)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
`
type SweepExpiredPremiumParams struct {
@ -671,6 +695,8 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
); err != nil {
return nil, err
}
@ -689,7 +715,7 @@ SET birthday_day = $1::int,
birthday_year = $3::int,
updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
`
type UpdateUserBirthdayParams struct {
@ -741,6 +767,8 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
@ -752,7 +780,7 @@ SET color_set = $1::boolean,
color_background_emoji_id = $3::bigint,
updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
`
type UpdateUserColorParams struct {
@ -804,6 +832,8 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
@ -812,19 +842,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 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
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
`
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,
@ -860,6 +900,8 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
@ -886,7 +928,7 @@ UPDATE users
SET personal_channel_id = $1::bigint,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
`
type UpdateUserPersonalChannelParams struct {
@ -931,6 +973,8 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
@ -940,7 +984,7 @@ UPDATE users
SET phone = $1::text,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
`
type UpdateUserPhoneParams struct {
@ -985,6 +1029,8 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
@ -996,7 +1042,7 @@ SET first_name = $2,
about = $4,
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
`
type UpdateUserProfileParams struct {
@ -1048,6 +1094,8 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
@ -1059,7 +1107,7 @@ SET profile_color_set = $1::boolean,
profile_color_background_emoji_id = $3::bigint,
updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
`
type UpdateUserProfileColorParams struct {
@ -1111,6 +1159,8 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
return i, err
}
@ -1120,7 +1170,7 @@ UPDATE users
SET username = $2,
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
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
`
type UpdateUserUsernameParams struct {
@ -1165,6 +1215,8 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
)
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

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

View file

@ -213,6 +213,21 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
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)
}
@ -233,6 +248,29 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 {
t.Fatalf("paid transfer = %+v err %v", transferred, err)
}
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

View file

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

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"
@ -134,27 +135,29 @@ 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),
LastSeenAt: int(row.LastSeenAt),
Contact: row.Contact,
Mutual: row.Mutual,
}
if row.Contact {
out.MyResults = append(out.MyResults, u)
@ -336,22 +339,110 @@ func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit in
return out, nil
}
// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=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{
@ -444,32 +535,34 @@ func escapeLike(s string) string {
}
func userFromModel(r sqlcgen.User) domain.User {
collectible := mustDecodeEmojiStatusCollectible(r.EmojiStatusCollectibleID, r.EmojiStatusCollectible)
u := domain.User{
ID: r.ID,
AccessHash: r.AccessHash,
Phone: r.Phone,
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),
Deleted: r.DeletedAt.Valid,
DeletionSource: domain.AccountDeletionSource(r.DeletionSource),
DeletionReason: r.DeletionReason,
CreatedAt: r.CreatedAt.Time,
AccountDeleteAt: r.AccountDeleteAt.Time,
ID: r.ID,
AccessHash: r.AccessHash,
Phone: r.Phone,
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,
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()
@ -478,6 +571,38 @@ func userFromModel(r sqlcgen.User) domain.User {
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 {
return domain.PeerColor{
HasColor: hasColor,