owpengram-server/internal/store/postgres/message_delete.go
2026-09-01 12:06:31 +03:00

665 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"sort"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
"time"
)
func (s *MessageStore) DeleteMessages(ctx context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) {
res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}
if req.OwnerUserID == 0 {
return res, fmt.Errorf("delete messages: missing owner user id")
}
ids := normalizeMessageIDs(req.IDs)
if len(ids) == 0 {
return res, nil
}
if len(ids) > domain.MaxDeleteMessageIDs {
return res, fmt.Errorf("delete messages: too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs)
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
lockUserIDs := []int64{req.OwnerUserID}
if req.Revoke {
peers, err := s.revokeDeleteLockPeers(ctx, req.OwnerUserID, ids)
if err != nil {
return res, fmt.Errorf("load delete revoke peers: %w", err)
}
lockUserIDs = append(lockUserIDs, peers...)
}
beginner, ok := s.db.(txBeginner)
if !ok {
return res, fmt.Errorf("delete messages: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return res, fmt.Errorf("begin delete messages tx: %w", err)
}
qtx := sqlcgen.New(tx)
committed := false
defer func() {
if committed {
return
}
_ = tx.Rollback(ctx)
}()
// advisory lock 串行化 owner 以及 revoke 会影响的私聊对端;必须在任何行锁前获取。
if err := lockUsersForUpdate(ctx, tx, lockUserIDs...); err != nil {
return res, fmt.Errorf("lock delete messages user: %w", err)
}
if err := lockDispatchOutboxAppendFences(ctx, tx, lockUserIDs); err != nil {
return res, fmt.Errorf("lock delete messages dispatch append fences: %w", err)
}
rows, err := qtx.DeleteMessageBoxesByIDs(ctx, sqlcgen.DeleteMessageBoxesByIDsParams{
OwnerUserID: req.OwnerUserID,
BoxIds: int32s(ids),
})
if err != nil {
return res, fmt.Errorf("delete message boxes by ids: %w", err)
}
deleted := deletedRowsFromIDRows(rows)
if req.Revoke && len(deleted) > 0 {
peerRows, err := qtx.DeleteMessageBoxesByPrivateMessages(ctx, privateMessageDeleteParams(deleted))
if err != nil {
return res, fmt.Errorf("delete revoked private message boxes: %w", err)
}
deleted = append(deleted, deletedRowsFromPrivateRows(peerRows)...)
}
res, err = s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, nil)
if err != nil {
return res, err
}
if err := tx.Commit(ctx); err != nil {
return res, fmt.Errorf("commit delete messages tx: %w", err)
}
committed = true
return res, nil
}
func (s *MessageStore) revokeDeleteLockPeers(ctx context.Context, ownerUserID int64, ids []int) ([]int64, error) {
rows, err := s.q.GetMessageBoxesByIDs(ctx, sqlcgen.GetMessageBoxesByIDsParams{
OwnerUserID: ownerUserID,
BoxIds: int32s(ids),
})
if err != nil {
return nil, err
}
seen := make(map[int64]struct{}, len(rows))
out := make([]int64, 0, len(rows))
for _, row := range rows {
if row.PeerType != string(domain.PeerTypeUser) || row.PeerID == 0 || row.PeerID == ownerUserID {
continue
}
if _, ok := seen[row.PeerID]; ok {
continue
}
seen[row.PeerID] = struct{}{}
out = append(out, row.PeerID)
}
return out, nil
}
type deletedBox struct {
ownerUserID int64
boxID int
privateMessageID int64
messageSenderID int64
peer domain.Peer
}
type deletedOwnerPeerKey struct {
userID int64
peer domain.Peer
}
type historyClearAnchor struct {
userID int64
peer domain.Peer
boxID int
uid int64
messageDate int
materialized bool
}
func (s *MessageStore) loadHistoryClearAnchor(ctx context.Context, q *sqlcgen.Queries, userID int64, peer domain.Peer) (historyClearAnchor, bool, error) {
top, err := q.TopVisibleMessageBoxByPeer(ctx, sqlcgen.TopVisibleMessageBoxByPeerParams{
OwnerUserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if errors.Is(err, pgx.ErrNoRows) {
return historyClearAnchor{}, false, nil
}
if err != nil {
return historyClearAnchor{}, false, fmt.Errorf("load history clear top: %w", err)
}
row, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
OwnerUserID: userID,
BoxID: top.BoxID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if err != nil {
return historyClearAnchor{}, false, fmt.Errorf("lock history clear top: %w", err)
}
media, err := decodeMessageMedia(row.MediaJson)
if err != nil {
return historyClearAnchor{}, false, fmt.Errorf("decode history clear top media: %w", err)
}
return historyClearAnchor{
userID: userID,
peer: peer,
boxID: int(row.BoxID),
uid: row.PrivateMessageID,
messageDate: int(row.MessageDate),
materialized: domain.IsHistoryClearServiceMessage(domain.Message{Media: media}),
}, true, nil
}
func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, ownerUserID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, date int, rows []deletedBox, anchors map[int64]historyClearAnchor) (domain.DeleteMessagesResult, error) {
res := domain.DeleteMessagesResult{OwnerUserID: ownerUserID}
if len(rows) == 0 && len(anchors) == 0 {
return res, nil
}
peersByOwner := make(map[int64]map[domain.Peer]struct{})
idsByOwner := make(map[int64][]int)
incomingDeletedByPeer := make(deletedUnreadMessages)
for _, row := range rows {
if row.ownerUserID == 0 || row.boxID == 0 {
continue
}
// Drop this box's media_references (storage GC); orphans the
// document/photo if this was its last live reference anywhere.
if err := removeMediaReferencesByKeyTx(ctx, db, domain.MediaRefKindMessageBox, messageBoxRefKey(row.ownerUserID, row.boxID)); err != nil {
return res, fmt.Errorf("remove deleted message media references: %w", err)
}
idsByOwner[row.ownerUserID] = append(idsByOwner[row.ownerUserID], row.boxID)
if row.peer.ID != 0 {
if peersByOwner[row.ownerUserID] == nil {
peersByOwner[row.ownerUserID] = make(map[domain.Peer]struct{})
}
peersByOwner[row.ownerUserID][row.peer] = struct{}{}
}
if row.peer.ID != 0 && row.messageSenderID != 0 && row.messageSenderID != row.ownerUserID {
key := deletedOwnerPeerKey{userID: row.ownerUserID, peer: row.peer}
if incomingDeletedByPeer[key] == nil {
incomingDeletedByPeer[key] = make(map[int]struct{})
}
incomingDeletedByPeer[key][row.boxID] = struct{}{}
}
}
for userID, anchor := range anchors {
if anchor.boxID <= 0 || anchor.peer.ID == 0 {
continue
}
if peersByOwner[userID] == nil {
peersByOwner[userID] = make(map[domain.Peer]struct{})
}
peersByOwner[userID][anchor.peer] = struct{}{}
if !anchor.materialized {
// 首次物化锚点会用一条 max_id=anchor 的真实 read update 覆盖该
// peer 的全部已读校正;不能再为本批删除的 incoming prefix 重复推进。
delete(incomingDeletedByPeer, deletedOwnerPeerKey{userID: userID, peer: anchor.peer})
}
}
// 按 owner 升序重建 dialog使两个反向 deleteX 删与 Y 的会话 / Y 删与 X 的会话)以一致顺序
// 获取 dialog 行锁,配合下方 watermark 的升序推进,彻底避免 delete-delete 之间的 AB-BA 死锁。
rebuildOwners := make([]int64, 0, len(peersByOwner))
for userID := range peersByOwner {
rebuildOwners = append(rebuildOwners, userID)
}
sort.Slice(rebuildOwners, func(i, j int) bool { return rebuildOwners[i] < rebuildOwners[j] })
for _, userID := range rebuildOwners {
for peer := range peersByOwner[userID] {
if anchor, ok := anchors[userID]; ok && anchor.peer == peer && !anchor.materialized {
// 先分配 edit PTS 并原位转换锚点,再按转换后的 outgoing
// 状态重算 dialog否则会短暂把 incoming anchor 计为未读。
continue
}
if err := rebuildDialogAfterMessageDelete(ctx, q, userID, peer); err != nil {
return res, err
}
}
}
readCorrectionsByOwner, err := loadDeleteUnreadCorrections(ctx, q, incomingDeletedByPeer, date)
if err != nil {
return res, err
}
ownerSet := make(map[int64]struct{}, len(idsByOwner)+len(anchors))
for userID := range idsByOwner {
ownerSet[userID] = struct{}{}
}
for userID, anchor := range anchors {
if !anchor.materialized {
ownerSet[userID] = struct{}{}
}
}
ownerIDs := make([]int64, 0, len(ownerSet))
for userID := range ownerSet {
ownerIDs = append(ownerIDs, userID)
}
sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] })
res.Deleted = make([]domain.DeletedMessagesForUser, 0, len(ownerIDs))
for _, userID := range ownerIDs {
ids := normalizeMessageIDs(idsByOwner[userID])
corrections := readCorrectionsByOwner[userID]
anchor, hasAnchor := anchors[userID]
materializeAnchor := hasAnchor && !anchor.materialized
totalPtsCount := len(ids) + len(corrections)
if materializeAnchor {
totalPtsCount += 2 // updateReadHistoryInbox + updateEditMessage
}
if totalPtsCount == 0 {
continue
}
pts, err := s.reservePtsN(ctx, db, userID, totalPtsCount)
if err != nil {
return res, fmt.Errorf("allocate delete messages pts: %w", err)
}
cursor := pts - totalPtsCount
item := domain.DeletedMessagesForUser{
UserID: userID,
MessageIDs: ids,
Pts: pts,
PtsCount: totalPtsCount,
Events: make([]domain.UpdateEvent, 0, 1+len(corrections)+2),
}
dispatchAuthKeyID := [8]byte{}
dispatchSessionID := int64(0)
if userID == ownerUserID {
dispatchAuthKeyID = excludeAuthKeyID
dispatchSessionID = excludeSessionID
}
if len(ids) > 0 {
cursor += len(ids)
event := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventDeleteMessages,
Pts: cursor,
PtsCount: len(ids),
Date: date,
MessageIDs: ids,
}
deleteIDsJSON, err := encodeEventMessageIDs(event.MessageIDs)
if err != nil {
return res, fmt.Errorf("encode sender delete receipt ids: %w", err)
}
senderPrivateIDs := make([]int64, 0, len(rows))
for _, row := range rows {
if row.ownerUserID == userID && row.messageSenderID == userID && row.privateMessageID != 0 {
senderPrivateIDs = append(senderPrivateIDs, row.privateMessageID)
}
}
if len(senderPrivateIDs) > 0 {
if _, err := db.Exec(ctx, `
UPDATE private_messages
SET sender_delete_pts = $3,
sender_delete_pts_count = $4,
sender_delete_date = $5,
sender_delete_message_ids = $6::jsonb
WHERE sender_user_id = $1
AND id = ANY($2::bigint[])
AND sender_box_id > 0`, userID, senderPrivateIDs, event.Pts, event.PtsCount, event.Date, deleteIDsJSON); err != nil {
return res, fmt.Errorf("save sender delete replay receipt: %w", err)
}
}
if err := appendDeleteMessagesEvent(ctx, q, event); err != nil {
return res, err
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(event.Pts),
EventType: string(domain.UpdateEventDeleteMessages),
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID),
ExcludeSessionID: dispatchSessionID,
}); err != nil {
return res, fmt.Errorf("enqueue delete messages dispatch: %w", err)
}
item.Event = event
item.Events = append(item.Events, event)
}
for _, correction := range corrections {
cursor++
correction.Pts = cursor
if err := appendUserUpdateEvent(ctx, db, q, userID, correction); err != nil {
return res, fmt.Errorf("append delete unread correction event: %w", err)
}
// dialog 快照必须与 update 流宣布的水位一致,否则后续
// readHistory 会以旧水位为基线重复发已读事件。
if err := q.AdvanceDialogReadInboxFloor(ctx, sqlcgen.AdvanceDialogReadInboxFloorParams{
UserID: userID,
PeerType: string(correction.Peer.Type),
PeerID: correction.Peer.ID,
ReadInboxMaxID: int32(correction.MaxID),
}); err != nil {
return res, fmt.Errorf("advance dialog read inbox after delete correction: %w", err)
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(correction.Pts),
EventType: string(domain.UpdateEventReadHistoryInbox),
}); err != nil {
return res, fmt.Errorf("enqueue delete unread correction dispatch: %w", err)
}
item.Events = append(item.Events, correction)
}
if materializeAnchor {
readPts := cursor + 1
editPts := readPts + 1
msg, err := materializeHistoryClearAnchorTx(ctx, db, anchor, editPts)
if err != nil {
return res, err
}
if err := rebuildDialogAfterMessageDelete(ctx, q, userID, anchor.peer); err != nil {
return res, err
}
readEvent := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventReadHistoryInbox,
Pts: readPts,
PtsCount: 1,
Date: date,
Peer: anchor.peer,
MaxID: anchor.boxID,
StillUnreadCount: 0,
}
if err := appendUserUpdateEvent(ctx, db, q, userID, readEvent); err != nil {
return res, fmt.Errorf("append history clear read event: %w", err)
}
if err := q.AdvanceDialogReadInboxFloor(ctx, sqlcgen.AdvanceDialogReadInboxFloorParams{
UserID: userID,
PeerType: string(anchor.peer.Type),
PeerID: anchor.peer.ID,
ReadInboxMaxID: int32(anchor.boxID),
}); err != nil {
return res, fmt.Errorf("advance history clear read inbox: %w", err)
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(readPts),
EventType: string(domain.UpdateEventReadHistoryInbox),
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID),
ExcludeSessionID: dispatchSessionID,
}); err != nil {
return res, fmt.Errorf("enqueue history clear read dispatch: %w", err)
}
editEvent := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventEditMessage,
Pts: editPts,
PtsCount: 1,
Date: date,
Message: msg,
}
if err := appendUserUpdateEvent(ctx, db, q, userID, editEvent); err != nil {
return res, fmt.Errorf("append history clear edit event: %w", err)
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(editPts),
EventType: string(domain.UpdateEventEditMessage),
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID),
ExcludeSessionID: dispatchSessionID,
}); err != nil {
return res, fmt.Errorf("enqueue history clear edit dispatch: %w", err)
}
item.Events = append(item.Events, readEvent, editEvent)
cursor = editPts
}
if cursor != pts {
return res, fmt.Errorf("delete history pts cursor %d does not reach reserved pts %d", cursor, pts)
}
res.Deleted = append(res.Deleted, item)
}
return res, nil
}
func materializeHistoryClearAnchorTx(ctx context.Context, db sqlcgen.DBTX, anchor historyClearAnchor, pts int) (domain.Message, error) {
msg := domain.NewHistoryClearMessage(anchor.userID, anchor.peer, anchor.boxID, anchor.uid, anchor.messageDate, pts)
mediaJSON, err := encodeMessageMedia(msg.Media)
if err != nil {
return domain.Message{}, fmt.Errorf("encode history clear media: %w", err)
}
tag, err := db.Exec(ctx, `
UPDATE message_boxes
SET from_user_id = $3,
ttl_period = 0,
expires_at = 0,
edit_date = 0,
hide_edited = false,
outgoing = true,
body = '',
entities = '[]'::jsonb,
silent = false,
noforwards = false,
reply_to_msg_id = 0,
reply_to_peer_type = '',
reply_to_peer_id = 0,
reply_to_top_id = 0,
reply_to_story_id = 0,
quote_text = '',
quote_entities = '[]'::jsonb,
quote_offset = 0,
fwd_from_peer_type = '',
fwd_from_peer_id = 0,
fwd_from_name = '',
fwd_date = 0,
fwd_saved_from_peer_type = '',
fwd_saved_from_peer_id = 0,
fwd_saved_from_msg_id = 0,
saved_peer_type = '',
saved_peer_id = 0,
pts = $4,
media = $5::jsonb,
media_unread = false,
reaction_unread = false,
pinned = false,
via_bot_id = 0,
grouped_id = 0,
effect = 0,
reply_markup = '{}'::jsonb,
rich_message = '{}'::jsonb
WHERE owner_user_id = $1
AND box_id = $2
AND NOT deleted`, anchor.userID, int32(anchor.boxID), anchor.userID, int32(pts), mediaJSON)
if err != nil {
return domain.Message{}, fmt.Errorf("materialize history clear anchor: %w", err)
}
if tag.RowsAffected() != 1 {
return domain.Message{}, fmt.Errorf("materialize history clear anchor: box %d disappeared", anchor.boxID)
}
if _, err := db.Exec(ctx, `DELETE FROM message_box_media WHERE owner_user_id = $1 AND box_id = $2`, anchor.userID, int32(anchor.boxID)); err != nil {
return domain.Message{}, fmt.Errorf("delete history clear media index: %w", err)
}
if _, err := db.Exec(ctx, `DELETE FROM saved_message_reaction_tags WHERE user_id = $1 AND message_box_id = $2`, anchor.userID, int32(anchor.boxID)); err != nil {
return domain.Message{}, fmt.Errorf("delete history clear saved tags: %w", err)
}
return msg, nil
}
func maxDeletedMessageID(ids map[int]struct{}) int {
maxID := 0
for id := range ids {
if id > maxID {
maxID = id
}
}
return maxID
}
func rebuildDialogAfterMessageDelete(ctx context.Context, q *sqlcgen.Queries, userID int64, peer domain.Peer) error {
top, err := q.TopVisibleMessageBoxByPeer(ctx, sqlcgen.TopVisibleMessageBoxByPeerParams{
OwnerUserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if errors.Is(err, pgx.ErrNoRows) {
if err := q.DeleteDialogByPeer(ctx, sqlcgen.DeleteDialogByPeerParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
}); err != nil {
return fmt.Errorf("delete empty dialog after message delete: %w", err)
}
return nil
}
if err != nil {
return fmt.Errorf("load top message after delete: %w", err)
}
if err := q.RefreshDialogAfterMessageDelete(ctx, sqlcgen.RefreshDialogAfterMessageDeleteParams{
TopMessageID: top.BoxID,
TopMessageDate: top.MessageDate,
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
}); err != nil {
return fmt.Errorf("refresh dialog after message delete: %w", err)
}
return nil
}
func appendDeleteMessagesEvent(ctx context.Context, q *sqlcgen.Queries, event domain.UpdateEvent) error {
messageIDs, err := encodeEventMessageIDs(event.MessageIDs)
if err != nil {
return err
}
if event.PtsCount == 0 {
event.PtsCount = len(event.MessageIDs)
}
if event.PtsCount == 0 {
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("{}"),
EmojiStatusPayload: []byte("{}"),
}); err != nil {
return fmt.Errorf("append delete messages event: %w", err)
}
return nil
}
func deletedRowsFromIDRows(rows []sqlcgen.DeleteMessageBoxesByIDsRow) []deletedBox {
out := make([]deletedBox, 0, len(rows))
for _, row := range rows {
out = append(out, deletedBox{
ownerUserID: row.OwnerUserID,
boxID: int(row.BoxID),
privateMessageID: row.PrivateMessageID,
messageSenderID: row.MessageSenderID,
peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
})
}
return out
}
func deletedRowsFromPeerRows(rows []sqlcgen.DeleteMessageBoxesByPeerRow) []deletedBox {
out := make([]deletedBox, 0, len(rows))
for _, row := range rows {
out = append(out, deletedBox{
ownerUserID: row.OwnerUserID,
boxID: int(row.BoxID),
privateMessageID: row.PrivateMessageID,
messageSenderID: row.MessageSenderID,
peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
})
}
return out
}
func deletedRowsFromPeerBatchRows(rows []sqlcgen.DeleteMessageBoxesByPeerBatchRow) []deletedBox {
out := make([]deletedBox, 0, len(rows))
for _, row := range rows {
out = append(out, deletedBox{
ownerUserID: row.OwnerUserID,
boxID: int(row.BoxID),
privateMessageID: row.PrivateMessageID,
messageSenderID: row.MessageSenderID,
peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
})
}
return out
}
func deletedRowsFromPrivateRows(rows []sqlcgen.DeleteMessageBoxesByPrivateMessagesRow) []deletedBox {
out := make([]deletedBox, 0, len(rows))
for _, row := range rows {
out = append(out, deletedBox{
ownerUserID: row.OwnerUserID,
boxID: int(row.BoxID),
privateMessageID: row.PrivateMessageID,
messageSenderID: row.MessageSenderID,
peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
})
}
return out
}
func privateMessageDeleteParams(rows []deletedBox) sqlcgen.DeleteMessageBoxesByPrivateMessagesParams {
senderIDs := make([]int64, 0, len(rows))
privateIDs := make([]int64, 0, len(rows))
ownerIDs := make([]int64, 0, len(rows)*2)
seen := make(map[[3]int64]struct{}, len(rows)*2)
for _, row := range rows {
if row.messageSenderID == 0 || row.privateMessageID == 0 {
continue
}
owners := privateMessageOwnerIDs(row.ownerUserID, 0)
if row.peer.Type == domain.PeerTypeUser {
owners = privateMessageOwnerIDs(row.ownerUserID, row.peer.ID)
}
for _, ownerID := range owners {
key := [3]int64{row.messageSenderID, row.privateMessageID, ownerID}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
senderIDs = append(senderIDs, row.messageSenderID)
privateIDs = append(privateIDs, row.privateMessageID)
ownerIDs = append(ownerIDs, ownerID)
}
}
return sqlcgen.DeleteMessageBoxesByPrivateMessagesParams{
MessageSenderIds: senderIDs,
PrivateMessageIds: privateIDs,
OwnerUserIds: ownerIDs,
}
}
func privateMessageOwnerIDs(first, second int64) []int64 {
switch {
case first == 0 && second == 0:
return nil
case first == 0 || first == second:
return []int64{second}
case second == 0:
return []int64{first}
default:
if first < second {
return []int64{first, second}
}
return []int64{second, first}
}
}