owpengram-server/internal/store/postgres/dialog.go

1006 lines
33 KiB
Go
Raw 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"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// DialogStore 用 PostgreSQL 实现 store.DialogStore。
type DialogStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewDialogStore 基于 pgx 连接池(或事务)创建 DialogStore。
func NewDialogStore(db sqlcgen.DBTX) *DialogStore {
return &DialogStore{db: db, q: sqlcgen.New(db)}
}
func (s *DialogStore) enrichDialogTopMessages(ctx context.Context, userID int64, messages []domain.Message) error {
if len(messages) == 0 {
return nil
}
return NewMessageStore(s.db).enrichPrivateMessageReactions(ctx, s.db, userID, messages)
}
func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
limit := filter.Limit
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
offsetPeerID := int64(0)
if filter.HasOffsetPeer {
offsetPeerID = filter.OffsetPeer.ID
}
folderParams := dialogFolderQueryParams(filter.Folder)
summaryRows, err := s.q.ListDialogSummaryByUser(ctx, sqlcgen.ListDialogSummaryByUserParams{
UserID: userID,
HasFolderID: filter.HasFolderID,
FolderID: pgInt32NonNegative(filter.FolderID),
FolderExcludeArchived: folderParams.excludeArchived,
FolderExcludeRead: folderParams.excludeRead,
FolderExcludePeerTypes: folderParams.excludeTypes,
FolderExcludePeerIds: folderParams.excludeIDs,
FolderIncludePeerTypes: folderParams.includeTypes,
FolderIncludePeerIds: folderParams.includeIDs,
FolderPinnedPeerTypes: folderParams.pinnedTypes,
FolderPinnedPeerIds: folderParams.pinnedIDs,
FolderContacts: folderParams.contacts,
FolderNonContacts: folderParams.nonContacts,
PinnedOnly: filter.PinnedOnly,
ExcludePinned: filter.ExcludePinned,
})
if err != nil {
return domain.DialogList{}, fmt.Errorf("list dialog summary: %w", err)
}
summary := make([]domain.Dialog, 0, len(summaryRows))
for _, row := range summaryRows {
summary = append(summary, domain.Dialog{
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
FolderID: int(row.FolderID),
TopMessage: int(row.TopMessageID),
TopMessageDate: int(row.TopMessageDate),
ReadInboxMaxID: int(row.ReadInboxMaxID),
ReadOutboxMaxID: int(row.ReadOutboxMaxID),
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
TTLPeriod: int(row.TtlPeriod),
ThemeEmoticon: row.ThemeEmoticon,
HasScheduled: row.HasScheduled,
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
})
}
out := domain.DialogList{
Dialogs: make([]domain.Dialog, 0, limit),
Count: len(summary),
Hash: dialogListHash(summary),
}
if len(summary) == 0 {
return out, nil
}
rows, err := s.q.ListDialogsByUser(ctx, sqlcgen.ListDialogsByUserParams{
UserID: userID,
LimitCount: int32(limit),
HasFolderID: filter.HasFolderID,
FolderID: pgInt32NonNegative(filter.FolderID),
FolderExcludeArchived: folderParams.excludeArchived,
FolderExcludeRead: folderParams.excludeRead,
FolderExcludePeerTypes: folderParams.excludeTypes,
FolderExcludePeerIds: folderParams.excludeIDs,
FolderIncludePeerTypes: folderParams.includeTypes,
FolderIncludePeerIds: folderParams.includeIDs,
FolderPinnedPeerTypes: folderParams.pinnedTypes,
FolderPinnedPeerIds: folderParams.pinnedIDs,
FolderContacts: folderParams.contacts,
FolderNonContacts: folderParams.nonContacts,
PinnedOnly: filter.PinnedOnly,
ExcludePinned: filter.ExcludePinned,
OffsetID: pgInt32NonNegative(filter.OffsetID),
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
HasOffsetPeer: filter.HasOffsetPeer,
OffsetPeerID: offsetPeerID,
})
if err != nil {
return domain.DialogList{}, fmt.Errorf("list dialogs: %w", err)
}
out.Messages = make([]domain.Message, 0, len(rows))
out.Users = make([]domain.User, 0, len(rows))
seenUsers := map[int64]struct{}{}
for _, row := range rows {
dialog := domain.Dialog{
Peer: domain.Peer{
Type: domain.PeerType(row.PeerType),
ID: row.PeerID,
},
FolderID: int(row.FolderID),
TopMessage: int(row.TopMessageID),
TopMessageDate: int(row.TopMessageDate),
ReadInboxMaxID: int(row.ReadInboxMaxID),
ReadOutboxMaxID: int(row.ReadOutboxMaxID),
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
TTLPeriod: int(row.TtlPeriod),
ThemeEmoticon: row.ThemeEmoticon,
HasScheduled: row.HasScheduled,
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
}
out.Dialogs = append(out.Dialogs, dialog)
if row.PeerUserID != 0 {
if _, ok := seenUsers[row.PeerUserID]; !ok {
seenUsers[row.PeerUserID] = struct{}{}
out.Users = append(out.Users, domain.User{
ID: row.PeerUserID,
AccessHash: row.PeerAccessHash,
Phone: row.PeerPhone,
FirstName: row.PeerFirstName,
LastName: row.PeerLastName,
Username: row.PeerUsername,
CountryCode: row.PeerCountryCode,
Verified: row.PeerVerified,
Support: row.PeerSupport,
Bot: row.PeerIsBot,
BotInfoVersion: int(row.PeerBotInfoVersion),
PremiumUntil: int(row.PeerPremiumUntil),
EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID,
EmojiStatusUntil: int(row.PeerEmojiStatusUntil),
LastSeenAt: int(row.PeerLastSeenAt),
Contact: row.PeerContact,
Mutual: row.PeerMutual,
})
}
}
if row.MessageID != 0 {
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message entities: %w", err)
}
media, err := decodeMessageMedia(row.MessageMediaJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message media: %w", err)
}
markup, err := decodeReplyMarkup(row.MessageReplyMarkupJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message reply markup: %w", err)
}
rich, err := decodeRichMessage(row.MessageRichMessageJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message rich message: %w", err)
}
// top message 必须带全量元数据TDesktop 把 getDialogs 的消息
// 先入缓存且不被后续 difference/getHistory 覆盖,缺 reply 等
// 字段会让置顶/回复服务消息永久渲染成 "Deleted message"。
silent, noforwards, reply, forward, err := messageMetadataFromFields(
row.MessageSilent,
row.MessageNoforwards,
row.MessageReplyToMsgID,
row.MessageReplyToPeerType,
row.MessageReplyToPeerID,
row.MessageReplyToTopID,
row.MessageReplyToStoryID,
row.MessageQuoteText,
row.MessageQuoteEntitiesJson,
row.MessageQuoteOffset,
row.MessageFwdFromPeerType,
row.MessageFwdFromPeerID,
row.MessageFwdFromName,
row.MessageFwdDate,
row.MessageFwdSavedFromPeerType,
row.MessageFwdSavedFromPeerID,
row.MessageFwdSavedFromMsgID,
)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message metadata: %w", err)
}
out.Messages = append(out.Messages, domain.Message{
ID: int(row.MessageID),
UID: row.MessagePrivateMessageID,
OwnerUserID: row.UserID,
Peer: dialog.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
Date: int(row.MessageDate),
EditDate: int(row.MessageEditDate),
Out: row.MessageOutgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.MessageBody,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
TTLPeriod: int(row.MessageTtlPeriod),
ExpiresAt: int(row.MessageExpiresAt),
MediaUnread: row.MessageMediaUnread,
ReactionUnread: row.MessageReactionUnread,
ViaBotID: row.MessageViaBotID,
GroupedID: row.MessageGroupedID,
Effect: row.MessageEffect,
ReplyMarkup: markup,
RichMessage: rich,
Pinned: row.MessagePinned,
SavedPeer: savedPeerFromFields(row.MessageSavedPeerType, row.MessageSavedPeerID),
})
}
}
if err := s.enrichDialogTopMessages(ctx, userID, out.Messages); err != nil {
return domain.DialogList{}, err
}
return out, nil
}
func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if len(peers) == 0 {
return domain.DialogList{}, nil
}
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
for _, peer := range peers {
if peer.Type == "" || peer.ID == 0 {
continue
}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
if len(peerTypes) == 0 {
return domain.DialogList{}, nil
}
rows, err := s.q.ListDialogsByPeers(ctx, sqlcgen.ListDialogsByPeersParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
})
if err != nil {
return domain.DialogList{}, fmt.Errorf("list dialogs by peers: %w", err)
}
out := domain.DialogList{
Dialogs: make([]domain.Dialog, 0, len(rows)),
Messages: make([]domain.Message, 0, len(rows)),
Users: make([]domain.User, 0, len(rows)),
}
seenUsers := map[int64]struct{}{}
for _, row := range rows {
dialog := domain.Dialog{
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
FolderID: int(row.FolderID),
TopMessage: int(row.TopMessageID),
TopMessageDate: int(row.TopMessageDate),
ReadInboxMaxID: int(row.ReadInboxMaxID),
ReadOutboxMaxID: int(row.ReadOutboxMaxID),
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
TTLPeriod: int(row.TtlPeriod),
ThemeEmoticon: row.ThemeEmoticon,
HasScheduled: row.HasScheduled,
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
}
out.Dialogs = append(out.Dialogs, dialog)
if row.PeerUserID != 0 {
if _, ok := seenUsers[row.PeerUserID]; !ok {
seenUsers[row.PeerUserID] = struct{}{}
out.Users = append(out.Users, domain.User{
ID: row.PeerUserID,
AccessHash: row.PeerAccessHash,
Phone: row.PeerPhone,
FirstName: row.PeerFirstName,
LastName: row.PeerLastName,
Username: row.PeerUsername,
CountryCode: row.PeerCountryCode,
Verified: row.PeerVerified,
Support: row.PeerSupport,
Bot: row.PeerIsBot,
BotInfoVersion: int(row.PeerBotInfoVersion),
PremiumUntil: int(row.PeerPremiumUntil),
EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID,
EmojiStatusUntil: int(row.PeerEmojiStatusUntil),
LastSeenAt: int(row.PeerLastSeenAt),
Contact: row.PeerContact,
Mutual: row.PeerMutual,
})
}
}
if row.MessageID != 0 {
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message entities: %w", err)
}
media, err := decodeMessageMedia(row.MessageMediaJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message media: %w", err)
}
markup, err := decodeReplyMarkup(row.MessageReplyMarkupJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message reply markup: %w", err)
}
rich, err := decodeRichMessage(row.MessageRichMessageJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message rich message: %w", err)
}
// top message 必须带全量元数据TDesktop 把 getDialogs 的消息
// 先入缓存且不被后续 difference/getHistory 覆盖,缺 reply 等
// 字段会让置顶/回复服务消息永久渲染成 "Deleted message"。
silent, noforwards, reply, forward, err := messageMetadataFromFields(
row.MessageSilent,
row.MessageNoforwards,
row.MessageReplyToMsgID,
row.MessageReplyToPeerType,
row.MessageReplyToPeerID,
row.MessageReplyToTopID,
row.MessageReplyToStoryID,
row.MessageQuoteText,
row.MessageQuoteEntitiesJson,
row.MessageQuoteOffset,
row.MessageFwdFromPeerType,
row.MessageFwdFromPeerID,
row.MessageFwdFromName,
row.MessageFwdDate,
row.MessageFwdSavedFromPeerType,
row.MessageFwdSavedFromPeerID,
row.MessageFwdSavedFromMsgID,
)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message metadata: %w", err)
}
out.Messages = append(out.Messages, domain.Message{
ID: int(row.MessageID),
UID: row.MessagePrivateMessageID,
OwnerUserID: row.UserID,
Peer: dialog.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
Date: int(row.MessageDate),
EditDate: int(row.MessageEditDate),
Out: row.MessageOutgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.MessageBody,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
TTLPeriod: int(row.MessageTtlPeriod),
ExpiresAt: int(row.MessageExpiresAt),
MediaUnread: row.MessageMediaUnread,
ReactionUnread: row.MessageReactionUnread,
ViaBotID: row.MessageViaBotID,
GroupedID: row.MessageGroupedID,
Effect: row.MessageEffect,
ReplyMarkup: markup,
RichMessage: rich,
Pinned: row.MessagePinned,
SavedPeer: savedPeerFromFields(row.MessageSavedPeerType, row.MessageSavedPeerID),
})
}
}
if err := s.enrichDialogTopMessages(ctx, userID, out.Messages); err != nil {
return domain.DialogList{}, err
}
out.Count = len(out.Dialogs)
out.Hash = dialogListHash(out.Dialogs)
return out, nil
}
func (s *DialogStore) Upsert(ctx context.Context, userID int64, dialog domain.Dialog) error {
if err := s.q.UpsertDialog(ctx, sqlcgen.UpsertDialogParams{
UserID: userID,
PeerType: string(dialog.Peer.Type),
PeerID: dialog.Peer.ID,
TopMessageID: int32(dialog.TopMessage),
TopMessageDate: int32(dialog.TopMessageDate),
ReadInboxMaxID: int32(dialog.ReadInboxMaxID),
ReadOutboxMaxID: int32(dialog.ReadOutboxMaxID),
UnreadCount: int32(dialog.UnreadCount),
UnreadMentionsCount: int32(dialog.UnreadMentions),
UnreadReactionsCount: int32(dialog.UnreadReactions),
Pinned: dialog.Pinned,
UnreadMark: dialog.UnreadMark,
}); err != nil {
return fmt.Errorf("upsert dialog: %w", err)
}
return nil
}
func (s *DialogStore) UpsertInbox(ctx context.Context, userID int64, dialog domain.Dialog) error {
if err := s.q.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{
UserID: userID,
PeerType: string(dialog.Peer.Type),
PeerID: dialog.Peer.ID,
TopMessageID: int32(dialog.TopMessage),
TopMessageDate: int32(dialog.TopMessageDate),
}); err != nil {
return fmt.Errorf("upsert inbox dialog: %w", err)
}
return nil
}
func (s *DialogStore) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error {
data, err := json.Marshal(draft)
if err != nil {
return fmt.Errorf("marshal dialog draft: %w", err)
}
if err := s.q.UpsertDialogDraft(ctx, sqlcgen.UpsertDialogDraftParams{
UserID: userID,
PeerType: string(draft.Peer.Type),
PeerID: draft.Peer.ID,
TopMessageID: int32(draft.TopMessageID),
Date: int32(draft.Date),
DraftJson: data,
}); err != nil {
return fmt.Errorf("upsert dialog draft: %w", err)
}
return nil
}
func (s *DialogStore) GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) {
var data []byte
err := s.db.QueryRow(ctx, `
SELECT draft
FROM dialog_drafts
WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3 AND top_message_id = $4`,
userID, string(peer.Type), peer.ID, int32(topMessageID)).Scan(&data)
if errors.Is(err, pgx.ErrNoRows) {
return domain.DialogDraft{}, false, nil
}
if err != nil {
return domain.DialogDraft{}, false, fmt.Errorf("get dialog draft: %w", err)
}
var draft domain.DialogDraft
if err := json.Unmarshal(data, &draft); err != nil {
return domain.DialogDraft{}, false, fmt.Errorf("decode dialog draft: %w", err)
}
return draft, true, nil
}
func (s *DialogStore) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) {
changed, err := s.q.DeleteDialogDraft(ctx, sqlcgen.DeleteDialogDraftParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
TopMessageID: int32(topMessageID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("delete dialog draft: %w", err)
}
return changed, nil
}
func (s *DialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
rows, err := s.q.ListDialogDrafts(ctx, sqlcgen.ListDialogDraftsParams{
UserID: userID,
LimitCount: int32(clampDialogDraftLimit(limit)),
})
if err != nil {
return nil, fmt.Errorf("list dialog drafts: %w", err)
}
return decodeDialogDrafts(rows)
}
func (s *DialogStore) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
rows, err := s.q.ClearDialogDrafts(ctx, sqlcgen.ClearDialogDraftsParams{
UserID: userID,
LimitCount: int32(clampDialogDraftLimit(limit)),
})
if err != nil {
return nil, fmt.Errorf("clear dialog drafts: %w", err)
}
return decodeDialogDrafts(rows)
}
func (s *DialogStore) MarkRead(ctx context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) {
row, err := s.q.MarkDialogRead(ctx, sqlcgen.MarkDialogReadParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
MaxID: pgInt32NonNegative(maxID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ReadHistoryResult{OwnerUserID: userID, Peer: peer, MaxID: maxID}, nil
}
return domain.ReadHistoryResult{}, fmt.Errorf("mark dialog read: %w", err)
}
return domain.ReadHistoryResult{
OwnerUserID: row.UserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
MaxID: int(row.ReadInboxMaxID),
StillUnreadCount: int(row.UnreadCount),
Changed: row.Changed,
}, nil
}
func (s *DialogStore) SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) {
row, err := s.q.SetDialogPinned(ctx, sqlcgen.SetDialogPinnedParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
Pinned: pinned,
})
if err != nil {
return false, 0, fmt.Errorf("set dialog pinned: %w", err)
}
return row.Changed, int(row.FolderID), nil
}
func (s *DialogStore) ReorderPinned(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) {
peerTypes, peerIDs := peerArrays(order)
changed := false
if force {
tag, err := s.db.Exec(ctx, `
WITH requested AS (
SELECT ($3::text[])[i] AS peer_type, ($4::bigint[])[i] AS peer_id
FROM generate_subscripts($4::bigint[], 1) AS g(i)
WHERE i <= cardinality($3::text[])
)
UPDATE dialogs d
SET pinned = false, pinned_order = 0, updated_at = now()
WHERE d.user_id = $1
AND d.pinned
AND d.folder_id = $2::int
AND NOT EXISTS (
SELECT 1
FROM requested r
WHERE r.peer_type = d.peer_type
AND r.peer_id = d.peer_id
)`, userID, folderID, peerTypes, peerIDs)
if err != nil {
return false, fmt.Errorf("clear pinned dialogs not in order: %w", err)
}
if tag.RowsAffected() > 0 {
changed = true
}
}
if len(peerTypes) == 0 {
return changed, nil
}
tag, err := s.db.Exec(ctx, `
WITH requested AS (
SELECT ($3::text[])[i] AS peer_type, ($4::bigint[])[i] AS peer_id, i::int AS pos
FROM generate_subscripts($4::bigint[], 1) AS g(i)
WHERE i <= cardinality($3::text[])
),
deduped AS (
SELECT DISTINCT ON (peer_type, peer_id)
peer_type,
peer_id,
(cardinality($4::bigint[]) - pos + 1)::int AS ord
FROM requested
ORDER BY peer_type, peer_id, pos
)
UPDATE dialogs d
SET pinned = true, pinned_order = deduped.ord, updated_at = now()
FROM deduped
WHERE d.user_id = $1
AND d.peer_type = deduped.peer_type
AND d.peer_id = deduped.peer_id
AND d.folder_id = $2::int
AND (NOT d.pinned OR d.pinned_order IS DISTINCT FROM deduped.ord)`, userID, folderID, peerTypes, peerIDs)
if err != nil {
return false, fmt.Errorf("reorder pinned dialogs: %w", err)
}
if tag.RowsAffected() > 0 {
changed = true
}
return changed, nil
}
func (s *DialogStore) SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
changed, err := s.q.SetDialogUnreadMark(ctx, sqlcgen.SetDialogUnreadMarkParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
Unread: unread,
})
if err != nil {
return false, fmt.Errorf("set dialog unread mark: %w", err)
}
return changed, nil
}
func (s *DialogStore) ListUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) {
rows, err := s.q.ListDialogUnreadMarks(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list dialog unread marks: %w", err)
}
out := make([]domain.Peer, 0, len(rows))
for _, row := range rows {
out = append(out, domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID})
}
return out, nil
}
func (s *DialogStore) SetChatTheme(ctx context.Context, userID int64, peer domain.Peer, emoticon string) (bool, error) {
if userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, nil
}
var current string
err := s.db.QueryRow(ctx, `
SELECT theme_emoticon
FROM dialogs
WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3`,
userID, string(peer.Type), peer.ID).Scan(&current)
if errors.Is(err, pgx.ErrNoRows) {
if emoticon == "" {
return false, nil
}
if _, err := s.db.Exec(ctx, `
INSERT INTO dialogs (user_id, peer_type, peer_id, theme_emoticon)
VALUES ($1, $2, $3, $4)`,
userID, string(peer.Type), peer.ID, emoticon); err != nil {
return false, fmt.Errorf("insert dialog chat theme: %w", err)
}
return true, nil
}
if err != nil {
return false, fmt.Errorf("get dialog chat theme: %w", err)
}
if current == emoticon {
return false, nil
}
if _, err := s.db.Exec(ctx, `
UPDATE dialogs
SET theme_emoticon = $4, updated_at = now()
WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3`,
userID, string(peer.Type), peer.ID, emoticon); err != nil {
return false, fmt.Errorf("update dialog chat theme: %w", err)
}
return true, nil
}
func (s *DialogStore) SetPeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
changed, err := s.q.SetPeerSettingsBarHidden(ctx, sqlcgen.SetPeerSettingsBarHiddenParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if err != nil {
return false, fmt.Errorf("set peer settings bar hidden: %w", err)
}
return changed, nil
}
func (s *DialogStore) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
hidden, err := s.q.GetPeerSettingsBarHidden(ctx, sqlcgen.GetPeerSettingsBarHiddenParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("get peer settings bar hidden: %w", err)
}
return hidden, nil
}
func (s *DialogStore) ListFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) {
rows, err := s.q.ListDialogFolders(ctx, userID)
if err != nil {
return domain.DialogFolderList{}, fmt.Errorf("list dialog folders: %w", err)
}
tagsEnabled := false
if enabled, err := s.q.GetDialogFolderTags(ctx, userID); err == nil {
tagsEnabled = enabled
} else if !errors.Is(err, pgx.ErrNoRows) {
return domain.DialogFolderList{}, fmt.Errorf("get dialog folder tags: %w", err)
}
out := domain.DialogFolderList{
TagsEnabled: tagsEnabled,
Folders: make([]domain.DialogFolder, 0, len(rows)),
}
for _, row := range rows {
folder, err := decodeDialogFolder(row.FilterJson)
if err != nil {
return domain.DialogFolderList{}, fmt.Errorf("decode dialog folder %d: %w", row.FilterID, err)
}
folder.ID = int(row.FilterID)
folder.IsChatlist = row.IsChatlist
out.Folders = append(out.Folders, folder)
}
return out, nil
}
func (s *DialogStore) GetFolder(ctx context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) {
row, err := s.q.GetDialogFolder(ctx, sqlcgen.GetDialogFolderParams{
UserID: userID,
FilterID: pgInt32NonNegative(folderID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.DialogFolder{}, false, nil
}
return domain.DialogFolder{}, false, fmt.Errorf("get dialog folder: %w", err)
}
folder, err := decodeDialogFolder(row.FilterJson)
if err != nil {
return domain.DialogFolder{}, false, fmt.Errorf("decode dialog folder: %w", err)
}
folder.ID = int(row.FilterID)
folder.IsChatlist = row.IsChatlist
return folder, true, nil
}
func (s *DialogStore) UpsertFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error {
data, err := json.Marshal(folder)
if err != nil {
return fmt.Errorf("marshal dialog folder: %w", err)
}
if err := s.q.UpsertDialogFolder(ctx, sqlcgen.UpsertDialogFolderParams{
UserID: userID,
FilterID: pgInt32NonNegative(folder.ID),
IsChatlist: folder.IsChatlist,
FilterJson: data,
}); err != nil {
return fmt.Errorf("upsert dialog folder: %w", err)
}
return nil
}
func (s *DialogStore) DeleteFolder(ctx context.Context, userID int64, folderID int) error {
if err := s.q.DeleteDialogFolder(ctx, sqlcgen.DeleteDialogFolderParams{
UserID: userID,
FilterID: pgInt32NonNegative(folderID),
}); err != nil {
return fmt.Errorf("delete dialog folder: %w", err)
}
return nil
}
func (s *DialogStore) ReorderFolders(ctx context.Context, userID int64, order []int) error {
if err := s.q.ReorderDialogFolders(ctx, sqlcgen.ReorderDialogFoldersParams{
UserID: userID,
FilterIds: int32s(order),
}); err != nil {
return fmt.Errorf("reorder dialog folders: %w", err)
}
return nil
}
func (s *DialogStore) SetFolderTagsEnabled(ctx context.Context, userID int64, enabled bool) error {
if err := s.q.SetDialogFolderTags(ctx, sqlcgen.SetDialogFolderTagsParams{
UserID: userID,
TagsEnabled: enabled,
}); err != nil {
return fmt.Errorf("set dialog folder tags: %w", err)
}
return nil
}
func (s *DialogStore) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error {
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
folderIDs := make([]int32, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, item := range peers {
if item.Peer.Type == "" || item.Peer.ID == 0 {
continue
}
if _, ok := seen[item.Peer]; ok {
continue
}
seen[item.Peer] = struct{}{}
peerTypes = append(peerTypes, string(item.Peer.Type))
peerIDs = append(peerIDs, item.Peer.ID)
folderIDs = append(folderIDs, pgInt32NonNegative(item.FolderID))
}
if len(peerTypes) == 0 {
return nil
}
if err := s.q.EditDialogPeerFolders(ctx, sqlcgen.EditDialogPeerFoldersParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
FolderIds: folderIDs,
}); err != nil {
return fmt.Errorf("edit dialog peer folders: %w", err)
}
return nil
}
func (s *DialogStore) SetArchivePinned(ctx context.Context, userID int64, pinned bool) (bool, error) {
changed, err := s.q.SetDialogArchivePinned(ctx, sqlcgen.SetDialogArchivePinnedParams{
UserID: userID,
Pinned: pinned,
})
if err != nil {
return false, fmt.Errorf("set dialog archive pinned: %w", err)
}
return changed, nil
}
func (s *DialogStore) ArchivePinned(ctx context.Context, userID int64) (bool, error) {
pinned, err := s.q.GetDialogArchivePinned(ctx, userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// 官方默认 archive folder 行在置顶区。
return true, nil
}
return false, fmt.Errorf("get dialog archive pinned: %w", err)
}
return pinned, nil
}
func (s *DialogStore) CountArchiveUnread(ctx context.Context, userID int64) (int, int, error) {
row, err := s.q.CountArchiveUnreadDialogs(ctx, userID)
if err != nil {
return 0, 0, fmt.Errorf("count archive unread dialogs: %w", err)
}
return int(row.UnreadPeers), int(row.UnreadMessages), nil
}
type dialogFolderParams struct {
contacts bool
nonContacts bool
excludeArchived bool
excludeRead bool
includeTypes []string
includeIDs []int64
pinnedTypes []string
pinnedIDs []int64
excludeTypes []string
excludeIDs []int64
}
func dialogFolderQueryParams(folder *domain.DialogFolder) dialogFolderParams {
if folder == nil {
return dialogFolderParams{}
}
includeTypes, includeIDs := folderPeerArrays(folder.IncludePeers)
pinnedTypes, pinnedIDs := folderPeerArrays(folder.PinnedPeers)
excludeTypes, excludeIDs := folderPeerArrays(folder.ExcludePeers)
return dialogFolderParams{
contacts: folder.Contacts,
nonContacts: folder.NonContacts,
excludeArchived: folder.ExcludeArchived,
excludeRead: folder.ExcludeRead,
includeTypes: includeTypes,
includeIDs: includeIDs,
pinnedTypes: pinnedTypes,
pinnedIDs: pinnedIDs,
excludeTypes: excludeTypes,
excludeIDs: excludeIDs,
}
}
func folderPeerArrays(peers []domain.DialogFolderPeer) ([]string, []int64) {
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, item := range peers {
peer := item.Peer
if peer.Type == "" || peer.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
return peerTypes, peerIDs
}
func decodeDialogFolder(data string) (domain.DialogFolder, error) {
if data == "" {
return domain.DialogFolder{}, nil
}
var folder domain.DialogFolder
if err := json.Unmarshal([]byte(data), &folder); err != nil {
return domain.DialogFolder{}, err
}
return folder, nil
}
func decodeDialogDrafts(rows []string) ([]domain.DialogDraft, error) {
out := make([]domain.DialogDraft, 0, len(rows))
for _, row := range rows {
draft, err := decodeDialogDraft(row)
if err != nil {
return nil, err
}
out = append(out, draft)
}
return out, nil
}
func decodeDialogDraft(data string) (domain.DialogDraft, error) {
if data == "" {
return domain.DialogDraft{}, nil
}
var draft domain.DialogDraft
if err := json.Unmarshal([]byte(data), &draft); err != nil {
return domain.DialogDraft{}, fmt.Errorf("decode dialog draft: %w", err)
}
if draft.Entities == nil {
draft.Entities = []domain.MessageEntity{}
}
return draft, nil
}
func clampDialogDraftLimit(limit int) int {
if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
return domain.MaxDialogDraftsPerUser
}
return limit
}
func peerArrays(peers []domain.Peer) ([]string, []int64) {
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if peer.Type == "" || peer.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
return peerTypes, peerIDs
}
func dialogListHash(dialogs []domain.Dialog) int64 {
if len(dialogs) == 0 {
return 0
}
h := fnv.New64a()
var buf [47]byte
for _, d := range dialogs {
binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID))
binary.LittleEndian.PutUint32(buf[8:12], uint32(d.FolderID))
binary.LittleEndian.PutUint32(buf[12:16], uint32(d.TopMessage))
binary.LittleEndian.PutUint32(buf[16:20], uint32(d.TopMessageDate))
binary.LittleEndian.PutUint32(buf[20:24], uint32(d.ReadInboxMaxID))
binary.LittleEndian.PutUint32(buf[24:28], uint32(d.ReadOutboxMaxID))
binary.LittleEndian.PutUint32(buf[28:32], uint32(d.UnreadCount))
binary.LittleEndian.PutUint32(buf[32:36], uint32(d.UnreadMentions))
binary.LittleEndian.PutUint32(buf[36:40], uint32(d.UnreadReactions))
if d.Pinned {
buf[40] = 1
} else {
buf[40] = 0
}
binary.LittleEndian.PutUint32(buf[41:45], uint32(d.PinnedOrder))
if d.UnreadMark {
buf[45] = 1
} else {
buf[45] = 0
}
if d.PeerSettingsBarHidden {
buf[46] = 1
} else {
buf[46] = 0
}
_, _ = h.Write(buf[:])
_, _ = h.Write([]byte(d.ThemeEmoticon))
}
return int64(h.Sum64())
}