package postgres import ( "context" "errors" "fmt" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "sort" "telesrv/internal/domain" "telesrv/internal/store/postgres/sqlcgen" "time" ) func (s *MessageStore) Create(ctx context.Context, msg domain.Message) (domain.Message, error) { if err := s.ensureOfficialSystemUser(ctx, msg); err != nil { return domain.Message{}, err } entities, err := encodeMessageEntities(msg.Entities) if err != nil { return domain.Message{}, err } if msg.Date == 0 { msg.Date = int(time.Now().Unix()) } if msg.ID == 0 { msg.ID, err = s.boxIDs.NextBoxID(ctx, msg.OwnerUserID) if err != nil { return domain.Message{}, fmt.Errorf("allocate login message box id: %w", err) } } row, err := s.q.CreateMessage(ctx, sqlcgen.CreateMessageParams{ OwnerUserID: msg.OwnerUserID, BoxID: int32(msg.ID), PeerType: string(msg.Peer.Type), PeerID: msg.Peer.ID, FromUserID: msg.From.ID, MessageDate: int32(msg.Date), Outgoing: msg.Out, Body: msg.Body, EntitiesJson: entities, Pts: int32(msg.Pts), }) if err != nil { return domain.Message{}, fmt.Errorf("create message: %w", err) } return messageFromCreateRow(row) } func (s *MessageStore) ensureOfficialSystemUser(ctx context.Context, msg domain.Message) error { if msg.Peer.Type != domain.PeerTypeUser && msg.From.Type != domain.PeerTypeUser { return nil } u, ok := domain.SystemUserByID(msg.Peer.ID) if !ok { u, ok = domain.SystemUserByID(msg.From.ID) } if !ok { return nil } if _, err := s.db.Exec(ctx, ` INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (id) DO UPDATE SET access_hash = EXCLUDED.access_hash, phone = EXCLUDED.phone, first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, username = EXCLUDED.username, country_code = EXCLUDED.country_code, verified = EXCLUDED.verified, support = EXCLUDED.support, about = EXCLUDED.about, is_bot = EXCLUDED.is_bot, bot_info_version = EXCLUDED.bot_info_version, updated_at = now() `, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About, u.Bot, u.BotInfoVersion); err != nil { return fmt.Errorf("ensure official system user: %w", err) } return nil } func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPrivateTextRequest) (res domain.SendPrivateTextResult, err error) { for attempt := 0; attempt < 2; attempt++ { res, err = s.sendPrivateTextOnce(ctx, req) if err == nil { return res, nil } if !isMessageBoxDuplicateKey(err) || attempt > 0 { return domain.SendPrivateTextResult{}, err } if recoverErr := s.bumpBoxIDCountersAfterDuplicate(ctx, req); recoverErr != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("%w; recover box id counters: %v", err, recoverErr) } } return domain.SendPrivateTextResult{}, err } func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendPrivateTextRequest) (res domain.SendPrivateTextResult, err error) { if req.SenderUserID == 0 || req.RecipientUserID == 0 { return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing user id") } if req.RandomID == 0 { return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing random id") } if req.Message == "" && req.Media.IsZero() { return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: empty message") } if req.Date == 0 { req.Date = int(time.Now().Unix()) } entities, err := encodeMessageEntities(req.Entities) if err != nil { return domain.SendPrivateTextResult{}, err } mediaJSON, err := encodeMessageMedia(req.Media) if err != nil { return domain.SendPrivateTextResult{}, err } // reply_markup(bot inline keyboard)随消息一并入双盒;普通用户发送恒 nil → "{}"。 replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup) if err != nil { return domain.SendPrivateTextResult{}, err } // rich_message(Layer 227 富文本)随消息一并入双盒;普通消息恒 nil → "{}"。 richMessageJSON, err := encodeRichMessage(req.RichMessage) if err != nil { return domain.SendPrivateTextResult{}, err } senderReply, recipientReply, err := s.resolvePrivateSendReply(ctx, req) if err != nil { return domain.SendPrivateTextResult{}, err } senderMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, senderReply, req.Forward) if err != nil { return domain.SendPrivateTextResult{}, err } recipientMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, recipientReply, req.Forward) if err != nil { return domain.SendPrivateTextResult{}, err } beginner, ok := s.db.(txBeginner) if !ok { return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: db does not support transactions") } var recipientBoxID, recipientPts int selfMessage := req.RecipientUserID == req.SenderUserID deliverRecipient := !selfMessage && !req.RecipientBlocked if selfMessage { savedPeer := domain.SavedPeerForSelfChat(req.SenderUserID, req.Forward) senderMeta.SavedPeerType = string(savedPeer.Type) senderMeta.SavedPeerID = savedPeer.ID } tx, err := beginner.Begin(ctx) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("begin send message tx: %w", err) } qtx := sqlcgen.New(tx) committed := false defer func() { if committed { return } _ = tx.Rollback(ctx) }() // 事务级 advisory lock 串行化涉及收发双方的并发写,在任何行锁之前获取,消除 watermark/dialog // 行锁的 AB-BA 死锁(A↔B 反向并发 send/read/edit)。 if err := lockUsersForUpdate(ctx, tx, req.SenderUserID, req.RecipientUserID); err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("lock send users: %w", err) } ttlPeriod := req.TTLPeriod if ttlPeriod == 0 { ttlPeriod, err = privateHistoryTTLPeriod(ctx, tx, req.SenderUserID, req.RecipientUserID) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("load private ttl: %w", err) } } expiresAt := 0 if ttlPeriod > 0 { expiresAt = req.Date + ttlPeriod } privateArg := sqlcgen.CreatePrivateMessageParams{ SenderUserID: req.SenderUserID, RecipientUserID: req.RecipientUserID, RandomID: req.RandomID, MessageDate: int32(req.Date), Body: req.Message, TtlPeriod: int32(ttlPeriod), ExpiresAt: int32(expiresAt), EntitiesJson: entities, MediaJson: mediaJSON, ReplyMarkupJson: replyMarkupJSON, RichMessageJson: richMessageJSON, ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, Effect: req.Effect, } applyCreatePrivateMessageMetadata(&privateArg, senderMeta) pm, err := qtx.CreatePrivateMessage(ctx, privateArg) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // 幂等重复:返回原消息盒;此时还没有分配 pts,重复发送不应制造额外事件。 dup, dupErr := s.duplicateSendResult(ctx, req.SenderUserID, req.RecipientUserID, req.RandomID) if dupErr != nil { return domain.SendPrivateTextResult{}, dupErr } dup.Duplicate = true return dup, nil } return domain.SendPrivateTextResult{}, fmt.Errorf("create private message: %w", err) } senderBoxID, err := s.boxIDs.NextBoxID(ctx, req.SenderUserID) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("allocate sender box id: %w", err) } senderPts, err := s.reservePts(ctx, tx, req.SenderUserID) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("allocate sender pts: %w", err) } if deliverRecipient { recipientBoxID, err = s.boxIDs.NextBoxID(ctx, req.RecipientUserID) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient box id: %w", err) } recipientPts, err = s.reservePts(ctx, tx, req.RecipientUserID) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient pts: %w", err) } } senderArg := sqlcgen.CreateMessageBoxParams{ OwnerUserID: req.SenderUserID, BoxID: int32(senderBoxID), PrivateMessageID: pm.ID, MessageSenderID: req.SenderUserID, PeerType: string(domain.PeerTypeUser), PeerID: req.RecipientUserID, FromUserID: req.SenderUserID, MessageDate: int32(req.Date), Outgoing: true, Body: req.Message, TtlPeriod: int32(ttlPeriod), ExpiresAt: int32(expiresAt), EntitiesJson: entities, Pts: int32(senderPts), MediaJson: mediaJSON, ReplyMarkupJson: replyMarkupJSON, RichMessageJson: richMessageJSON, ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, Effect: req.Effect, // voice/round 在发送者自己的副本上也保持"未听",直到对端 // readMessageContents 触发 sender 侧清除;发给自己无人可听,恒已读。 MediaUnread: req.Media.HasUnreadPayload() && !selfMessage, ReactionUnread: false, } applyCreateMessageBoxMetadata(&senderArg, senderMeta) senderRow, err := qtx.CreateMessageBox(ctx, senderArg) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("create sender box: %w", err) } sender := messageFromBoxRow(senderRow) // 共享媒体索引(0118):发送者侧 box 按媒体类别建索引(peer=收件人)。 if err := insertMessageBoxMediaIndexTx(ctx, tx, req.SenderUserID, req.RecipientUserID, int(senderBoxID), req.Date, req.Media, req.Entities); err != nil { return domain.SendPrivateTextResult{}, err } if err := qtx.UpsertOutboxDialog(ctx, sqlcgen.UpsertOutboxDialogParams{ UserID: req.SenderUserID, PeerType: string(domain.PeerTypeUser), PeerID: req.RecipientUserID, TopMessageID: int32(sender.ID), TopMessageDate: int32(sender.Date), }); err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("upsert sender dialog: %w", err) } if err := appendNewMessageEvent(ctx, qtx, sender); err != nil { return domain.SendPrivateTextResult{}, err } if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ TargetUserID: req.SenderUserID, Pts: int32(senderPts), EventType: string(domain.UpdateEventNewMessage), ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID), ExcludeSessionID: req.OriginSessionID, }); err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue sender dispatch: %w", err) } recipient := domain.Message{} if selfMessage { recipient = sender } if deliverRecipient { recipientArg := sqlcgen.CreateMessageBoxParams{ OwnerUserID: req.RecipientUserID, BoxID: int32(recipientBoxID), PrivateMessageID: pm.ID, MessageSenderID: req.SenderUserID, PeerType: string(domain.PeerTypeUser), PeerID: req.SenderUserID, FromUserID: req.SenderUserID, MessageDate: int32(req.Date), Outgoing: false, Body: req.Message, TtlPeriod: int32(ttlPeriod), ExpiresAt: int32(expiresAt), EntitiesJson: entities, Pts: int32(recipientPts), MediaJson: mediaJSON, ReplyMarkupJson: replyMarkupJSON, RichMessageJson: richMessageJSON, ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, Effect: req.Effect, MediaUnread: req.Media.HasUnreadPayload(), ReactionUnread: false, } applyCreateMessageBoxMetadata(&recipientArg, recipientMeta) recipientRow, err := qtx.CreateMessageBox(ctx, recipientArg) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("create recipient box: %w", err) } recipient = messageFromBoxRow(recipientRow) // 共享媒体索引(0118):收件人侧 box 按媒体类别建索引(peer=发送者)。 if err := insertMessageBoxMediaIndexTx(ctx, tx, req.RecipientUserID, req.SenderUserID, int(recipientBoxID), req.Date, req.Media, req.Entities); err != nil { return domain.SendPrivateTextResult{}, err } if err := qtx.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{ UserID: req.RecipientUserID, PeerType: string(domain.PeerTypeUser), PeerID: req.SenderUserID, TopMessageID: int32(recipient.ID), TopMessageDate: int32(recipient.Date), }); err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("upsert recipient dialog: %w", err) } if err := appendNewMessageEvent(ctx, qtx, recipient); err != nil { return domain.SendPrivateTextResult{}, err } if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ TargetUserID: req.RecipientUserID, Pts: int32(recipientPts), EventType: string(domain.UpdateEventNewMessage), ExcludeAuthKeyID: 0, ExcludeSessionID: 0, }); err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue recipient dispatch: %w", err) } } if err := tx.Commit(ctx); err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("commit send message tx: %w", err) } committed = true return domain.SendPrivateTextResult{ SenderMessage: sender, RecipientMessage: recipient, SenderEvent: eventFromMessage(sender), RecipientEvent: eventFromMessage(recipient), }, nil } type boxIDCounterBumper interface { BumpBoxIDAtLeast(ctx context.Context, userID int64, floor int) error } func (s *MessageStore) bumpBoxIDCountersAfterDuplicate(ctx context.Context, req domain.SendPrivateTextRequest) error { bumper, ok := s.boxIDs.(boxIDCounterBumper) if !ok { return nil } userIDs := []int64{req.SenderUserID} if req.RecipientUserID != 0 && req.RecipientUserID != req.SenderUserID { userIDs = append(userIDs, req.RecipientUserID) } for _, userID := range userIDs { maxID, err := s.q.MaxMessageBoxID(ctx, userID) if err != nil { return fmt.Errorf("max message box id for %d: %w", userID, err) } if err := bumper.BumpBoxIDAtLeast(ctx, userID, int(maxID)); err != nil { return fmt.Errorf("bump box id for %d: %w", userID, err) } } return nil } func isMessageBoxDuplicateKey(err error) bool { var pgErr *pgconn.PgError if !errors.As(err, &pgErr) { return false } return pgErr.Code == "23505" && strings.Contains(pgErr.ConstraintName, "message_boxes") } func (s *MessageStore) duplicateSendResult(ctx context.Context, senderUserID, recipientUserID, randomID int64) (domain.SendPrivateTextResult, error) { pm, err := s.q.GetPrivateMessageByRandomID(ctx, sqlcgen.GetPrivateMessageByRandomIDParams{ SenderUserID: senderUserID, RandomID: randomID, }) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate private message: %w", err) } senderRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ OwnerUserID: senderUserID, PrivateMessageID: pm.ID, }) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate sender box: %w", err) } sender := messageFromGetBoxRow(senderRow) recipient := domain.Message{} if recipientUserID == senderUserID { recipient = sender } if recipientUserID != senderUserID { recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ OwnerUserID: recipientUserID, PrivateMessageID: pm.ID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.SendPrivateTextResult{ SenderMessage: sender, SenderEvent: eventFromMessage(sender), RecipientEvent: domain.UpdateEvent{}, }, nil } return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate recipient box: %w", err) } recipient = messageFromGetBoxRow(recipientRow) } return domain.SendPrivateTextResult{ SenderMessage: sender, RecipientMessage: recipient, SenderEvent: eventFromMessage(sender), RecipientEvent: eventFromMessage(recipient), }, nil } func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) { if req.ReplyTo == nil { return nil, nil, nil } if req.ReplyTo.StoryID > 0 { // story 回复(评论):无源消息可查;story 作者就是会话对端(recipient),双盒同持。 if req.ReplyTo.StoryID > domain.MaxStoryID { return nil, nil, domain.ErrReplyMessageIDInvalid } reply := &domain.MessageReply{ StoryID: req.ReplyTo.StoryID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, } return cloneMessageReply(reply), cloneMessageReply(reply), nil } if req.ReplyTo.MessageID <= 0 || req.ReplyTo.MessageID > domain.MaxMessageBoxID { return nil, nil, domain.ErrReplyMessageIDInvalid } peer := req.ReplyTo.Peer if peer.ID == 0 { peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID} } if peer.Type != domain.PeerTypeUser || peer.ID != req.RecipientUserID { return nil, nil, domain.ErrReplyMessageIDInvalid } source, err := s.q.GetMessageBoxForReply(ctx, sqlcgen.GetMessageBoxForReplyParams{ OwnerUserID: req.SenderUserID, PeerType: string(peer.Type), PeerID: peer.ID, BoxID: int32(req.ReplyTo.MessageID), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil, domain.ErrReplyMessageIDInvalid } return nil, nil, fmt.Errorf("get reply message: %w", err) } senderReply := cloneMessageReply(req.ReplyTo) senderReply.MessageID = int(source.BoxID) senderReply.Peer = peer if req.SenderUserID == req.RecipientUserID { return senderReply, cloneMessageReply(senderReply), nil } recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ OwnerUserID: req.RecipientUserID, PrivateMessageID: source.PrivateMessageID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return senderReply, nil, nil } return nil, nil, fmt.Errorf("get recipient reply message: %w", err) } recipientReply := cloneMessageReply(senderReply) recipientReply.MessageID = int(recipientRow.BoxID) recipientReply.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} return senderReply, recipientReply, nil } func appendNewMessageEvent(ctx context.Context, q *sqlcgen.Queries, msg domain.Message) error { boxID := int32(msg.ID) 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, }); err != nil { return fmt.Errorf("append new message event: %w", err) } return nil } // lockUsersForUpdate 在事务开始处用事务级 advisory lock 串行化所有涉及指定用户的并发写事务。 // advisory lock 与行锁处于独立锁空间,且按 user_id 升序获取,因此:① 不会与后续 dialog / // watermark / box 行锁交叉成跨类型死锁;② 任意两个共享某用户的写事务(send/read/edit/delete 对 // 收发双方的并发操作)被完全串行化,从根上消除它们在 watermark 与 dialog 行上因加锁顺序相反 // 导致的 AB-BA 死锁——既包含本次 watermark 优化新引入的(user_update_watermarks FOR UPDATE), // 也包含 dialog upsert 既有的反向行锁。advisory xact lock 在事务结束自动释放;同对用户本就竞争 // 这些行(天然串行),不额外降并发,不同用户集合的事务仍并行。**必须在任何行锁之前调用。** func lockUsersForUpdate(ctx context.Context, tx pgx.Tx, userIDs ...int64) error { if len(userIDs) == 0 { return nil } unique := make([]int64, 0, len(userIDs)) seen := make(map[int64]struct{}, len(userIDs)) for _, id := range userIDs { if id <= 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} unique = append(unique, id) } sort.Slice(unique, func(i, j int) bool { return unique[i] < unique[j] }) for _, id := range unique { if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", id); err != nil { return fmt.Errorf("advisory lock user %d: %w", id, err) } } return nil } func applyCreatePrivateMessageMetadata(arg *sqlcgen.CreatePrivateMessageParams, meta messageMetadataParams) { arg.Silent = meta.Silent arg.Noforwards = meta.Noforwards arg.ReplyToMsgID = meta.ReplyToMsgID arg.ReplyToPeerType = meta.ReplyToPeerType arg.ReplyToPeerID = meta.ReplyToPeerID arg.ReplyToTopID = meta.ReplyToTopID arg.ReplyToStoryID = meta.ReplyToStoryID arg.QuoteText = meta.QuoteText arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON arg.QuoteOffset = meta.QuoteOffset arg.FwdFromPeerType = meta.FwdFromPeerType arg.FwdFromPeerID = meta.FwdFromPeerID arg.FwdFromName = meta.FwdFromName arg.FwdDate = meta.FwdDate } func applyCreateMessageBoxMetadata(arg *sqlcgen.CreateMessageBoxParams, meta messageMetadataParams) { arg.Silent = meta.Silent arg.Noforwards = meta.Noforwards arg.ReplyToMsgID = meta.ReplyToMsgID arg.ReplyToPeerType = meta.ReplyToPeerType arg.ReplyToPeerID = meta.ReplyToPeerID arg.ReplyToTopID = meta.ReplyToTopID arg.ReplyToStoryID = meta.ReplyToStoryID arg.QuoteText = meta.QuoteText arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON arg.QuoteOffset = meta.QuoteOffset arg.FwdFromPeerType = meta.FwdFromPeerType arg.FwdFromPeerID = meta.FwdFromPeerID arg.FwdFromName = meta.FwdFromName arg.FwdDate = meta.FwdDate arg.FwdSavedFromPeerType = meta.FwdSavedFromPeerType arg.FwdSavedFromPeerID = meta.FwdSavedFromPeerID arg.FwdSavedFromMsgID = meta.FwdSavedFromMsgID arg.SavedPeerType = meta.SavedPeerType arg.SavedPeerID = meta.SavedPeerID } func privateHistoryTTLPeriod(ctx context.Context, db sqlcgen.DBTX, ownerUserID, peerUserID int64) (int, error) { if ownerUserID == 0 || peerUserID == 0 { return 0, nil } var period int err := db.QueryRow(ctx, ` SELECT COALESCE(NULLIF(d.ttl_period, 0), u.default_history_ttl_period, 0)::int FROM users u LEFT JOIN dialogs d ON d.user_id = u.id AND d.peer_type = 'user' AND d.peer_id = $2 WHERE u.id = $1 `, ownerUserID, peerUserID).Scan(&period) if errors.Is(err, pgx.ErrNoRows) { return 0, nil } if err != nil { return 0, err } if period < 0 { return 0, nil } return period, nil } func messageFromCreateRow(row sqlcgen.CreateMessageRow) (domain.Message, error) { entities, err := decodeMessageEntities(row.EntitiesJson) if err != nil { return domain.Message{}, fmt.Errorf("decode message entities: %w", err) } return domain.Message{ ID: int(row.BoxID), UID: row.PrivateMessageID, OwnerUserID: row.OwnerUserID, Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, Date: int(row.MessageDate), EditDate: int(row.EditDate), HideEdited: row.HideEdited, Out: row.Outgoing, Body: row.Body, Entities: entities, Pts: int(row.Pts), }, nil }