owpengram-server/internal/app/dialogs/service.go

1053 lines
32 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 dialogs
import (
"context"
"encoding/binary"
"errors"
"hash/fnv"
"reflect"
"sort"
"unicode/utf8"
"telesrv/internal/app/userprojection"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// PremiumChecker 报告用户当前是否有效会员pin 上限双档判断用)。
type PremiumChecker func(ctx context.Context, userID int64) bool
// Service 提供会话列表查询。
type Service struct {
dialogs store.DialogStore
channels store.ChannelStore
contacts store.ContactStore
photos userprojection.ProfilePhotoProvider
privacy userprojection.PrivacyEvaluator
premium PremiumChecker
projector *userprojection.Projector
versions store.ReadModelVersionStore
peerCache *dialogPeerReadModelCache
listHashCache *dialogListHashCache
}
// Option adjusts optional dialogs service dependencies.
type Option func(*Service)
// WithContactStore enables viewer-specific user projection for dialog users.
func WithContactStore(c store.ContactStore) Option {
return func(s *Service) { s.contacts = c }
}
// WithPremiumChecker 启用 pin 上限的 premium 双档(缺省一律按默认档)。
func WithPremiumChecker(p PremiumChecker) Option {
return func(s *Service) { s.premium = p }
}
// WithPhotoProvider enables current profile photo enrichment for dialog users.
func WithPhotoProvider(p userprojection.ProfilePhotoProvider) Option {
return func(s *Service) { s.photos = p }
}
// WithPrivacyEvaluator enables viewer-specific privacy projection for dialog users.
func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
return func(s *Service) { s.privacy = p }
}
// WithReadModelVersions enables durable version-token backed peer dialog caching.
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
return func(s *Service) { s.versions = v }
}
// NewService 创建 dialogs 服务。
func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Service {
s := &Service{
dialogs: dialogs,
peerCache: newDialogPeerReadModelCache(defaultDialogPeerReadModelTTL),
listHashCache: newDialogListHashCache(defaultDialogListHashCacheTTL),
}
if len(channels) > 0 {
s.channels = channels[0]
}
s.rebuildProjector()
return s
}
// Configure applies optional dependencies after construction.
func (s *Service) Configure(opts ...Option) *Service {
if s == nil {
return s
}
for _, opt := range opts {
opt(s)
}
s.rebuildProjector()
return s
}
func (s *Service) rebuildProjector() {
if s == nil {
return
}
s.projector = userprojection.New(
userprojection.WithContactStore(s.contacts),
userprojection.WithPhotoProvider(s.photos),
userprojection.WithPrivacyEvaluator(s.privacy),
)
}
// GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。
func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
return s.getDialogs(ctx, userID, filter, false)
}
// getDialogs 是 GetDialogs 的实现。lightweight=true 跳过草稿附加、viewer 投影与
// list-hash 写回,供 attachArchiveSummary 取归档顶部会话用:归档摘要只需 top
// peer/message,草稿无意义;且追加进来的归档 users 会被外层 GetDialogs 的
// projectDialogUsers 统一投影,内层再投影纯属重复(原归档递归走完整 GetDialogs
// 会多跑一次 ListDrafts + 一次投影)。
func (s *Service) getDialogs(ctx context.Context, userID int64, filter domain.DialogFilter, lightweight bool) (domain.DialogList, error) {
if s == nil || userID == 0 {
return domain.DialogList{}, nil
}
if filter.HasFolderID && filter.FolderID >= domain.DialogCustomFolderMinID && filter.Folder == nil {
if s.dialogs == nil {
return domain.DialogList{}, nil
}
folder, found, err := s.dialogs.GetFolder(ctx, userID, filter.FolderID)
if err != nil {
return domain.DialogList{}, err
}
if !found {
return domain.DialogList{}, nil
}
filter.Folder = &folder
}
// 在加载任何会话状态前快照 list-hash epoch若加载/投影期间发生 dialog_light 写失效,
// rememberDialogListHash 会据此拒绝写回 stale hash避免后续 getDialogs 误返 NotModified。
listHashEpoch := s.listHashCache.cacheEpoch()
var out domain.DialogList
if s.dialogs != nil {
list, err := s.dialogs.ListByUser(ctx, userID, filter)
if err != nil {
return domain.DialogList{}, err
}
out = mergeDialogLists(out, list)
}
if s.channels != nil {
list, err := s.channels.ListChannelDialogs(ctx, userID, filter)
if err != nil {
return domain.DialogList{}, err
}
out = mergeChannelDialogs(out, list)
}
sortDialogList(out.Dialogs)
limit := filter.Limit
if limit <= 0 || limit > 100 {
limit = 100
}
if len(out.Dialogs) > limit {
keep := make(map[domain.Peer]struct{}, limit)
for _, d := range out.Dialogs[:limit] {
keep[d.Peer] = struct{}{}
}
out.Dialogs = out.Dialogs[:limit]
out.Messages = filterPrivateMessagesByPeer(out.Messages, keep)
out.ChannelMessages = filterChannelMessagesByPeer(out.ChannelMessages, keep)
out.Channels = filterChannelsByPeer(out.Channels, keep)
}
if out.Count == 0 {
out.Count = len(out.Dialogs)
}
if err := s.attachArchiveSummary(ctx, userID, filter, &out); err != nil {
return domain.DialogList{}, err
}
if lightweight {
// 归档摘要顶部会话:不附草稿、不投影(由外层统一投影)、不写 list-hash。
return out, nil
}
if err := s.attachDrafts(ctx, userID, &out); err != nil {
return domain.DialogList{}, err
}
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
return domain.DialogList{}, err
}
s.rememberDialogListHash(userID, filter, out, listHashEpoch)
return out, nil
}
// attachArchiveSummary 在主列表第一页响应上聚合归档摘要TDesktop 只能靠
// 响应头部的 dialogFolder 条目发现 archive新登录设备没有任何 update 可
// 重放),缺少它归档会话将彻底不可见。归档/自定义 filter/置顶/翻页请求不附加。
func (s *Service) attachArchiveSummary(ctx context.Context, userID int64, filter domain.DialogFilter, out *domain.DialogList) error {
if s == nil || out == nil {
return nil
}
if filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID {
return nil
}
// exclude_pinned 请求按官方语义排除 folder 条目archive 行属 pinned 集合)。
// PinnedOnlygetPinnedDialogs不能跳过DrKLO 主列表 getDialogs 一律带
// exclude_pinnedarchive 行的发现完全依赖 getPinnedDialogs 响应里的
// dialogFolder 条目fetchFolderInLoadedPinnedDialogs
if filter.ExcludePinned {
return nil
}
if filter.OffsetID != 0 || filter.OffsetDate != 0 || filter.HasOffsetPeer {
return nil
}
top, err := s.getDialogs(ctx, userID, domain.DialogFilter{
HasFolderID: true,
FolderID: domain.DialogArchiveFolderID,
Limit: 1,
}, true)
if err != nil {
return err
}
if len(top.Dialogs) == 0 {
return nil
}
unreadPeers, unreadMessages := 0, 0
if s.dialogs != nil {
peers, messages, err := s.dialogs.CountArchiveUnread(ctx, userID)
if err != nil {
return err
}
unreadPeers += peers
unreadMessages += messages
}
if s.channels != nil {
peers, messages, err := s.channels.CountChannelArchiveUnread(ctx, userID)
if err != nil {
return err
}
unreadPeers += peers
unreadMessages += messages
}
archivePinned := true
if s.dialogs != nil {
pinned, err := s.dialogs.ArchivePinned(ctx, userID)
if err != nil {
return err
}
archivePinned = pinned
}
// getPinnedDialogs 只返回置顶集合archive 行被 unpin 后不再属于它。
if filter.PinnedOnly && !archivePinned {
return nil
}
topDialog := top.Dialogs[0]
out.ArchiveSummary = &domain.DialogArchiveSummary{
TopPeer: topDialog.Peer,
TopMessage: topDialog.TopMessage,
UnreadPeersCount: unreadPeers,
UnreadMessagesCount: unreadMessages,
Pinned: archivePinned,
}
// dialogFolder.peer 指向的会话对象必须随响应下发TDesktop
// Folder::applyDialog 会立即解引用该 peerowner().history(peerId))。
out.Messages = append(out.Messages, top.Messages...)
out.ChannelMessages = append(out.ChannelMessages, top.ChannelMessages...)
out.Users = append(out.Users, top.Users...)
out.Channels = append(out.Channels, top.Channels...)
return nil
}
// GetPeerDialogs 返回指定 peer 的会话摘要。缺失的 peer 由 store 按空会话占位返回。
func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if s == nil || userID == 0 || len(peers) == 0 {
return domain.DialogList{}, nil
}
if len(peers) > domain.MaxDialogFolderPeers {
return domain.DialogList{}, domain.ErrChannelInvalid
}
userPeers := make([]domain.Peer, 0, len(peers))
channelIDs := make([]int64, 0, len(peers))
for _, peer := range peers {
switch peer.Type {
case domain.PeerTypeUser:
userPeers = append(userPeers, peer)
case domain.PeerTypeChannel:
channelIDs = append(channelIDs, peer.ID)
}
}
var out domain.DialogList
if len(userPeers) > 0 && s.dialogs != nil {
list, err := s.userPeerDialogsReadModel(ctx, userID, userPeers)
if err != nil {
return domain.DialogList{}, err
}
out = mergeDialogLists(out, list)
}
if len(channelIDs) > 0 && s.channels != nil {
channelOut, err := s.channelPeerDialogsReadModel(ctx, userID, channelIDs)
if err != nil {
return domain.DialogList{}, err
}
out = mergeDialogLists(out, channelOut)
}
return out, nil
}
func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID int64, channelIDs []int64, out domain.DialogList) (domain.DialogList, error) {
if s == nil || s.channels == nil || userID == 0 || len(channelIDs) == 0 {
return out, nil
}
present := make(map[int64]struct{}, len(out.Dialogs))
for _, dialog := range out.Dialogs {
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 {
present[dialog.Peer.ID] = struct{}{}
}
}
seen := make(map[int64]struct{}, len(channelIDs))
missingIDs := make([]int64, 0, len(channelIDs))
for _, channelID := range channelIDs {
if channelID == 0 {
continue
}
if _, ok := seen[channelID]; ok {
continue
}
seen[channelID] = struct{}{}
if _, ok := present[channelID]; ok {
continue
}
missingIDs = append(missingIDs, channelID)
}
if len(missingIDs) == 0 {
return out, nil
}
views, err := s.channels.GetChannels(ctx, userID, missingIDs)
if err != nil {
if isChannelPreviewAccessError(err) {
return out, nil
}
return domain.DialogList{}, err
}
viewsByID := make(map[int64]domain.ChannelView, len(views))
for _, view := range views {
if view.Channel.ID != 0 {
viewsByID[view.Channel.ID] = view
}
}
for _, channelID := range missingIDs {
view, ok := viewsByID[channelID]
if !ok || view.Forbidden {
continue
}
// Linked discussion guests need a transient peer-dialog snapshot so
// TDesktop can finish materializing the comments History after
// requestSelf. ChannelLeft keeps the snapshot out of the main chat list,
// and Guest guarantees this path never turns an ordinary public preview
// into a dialog.
if view.Self.Status != domain.ChannelMemberActive && !view.Self.Guest {
continue
}
history, err := s.channels.ListChannelHistory(ctx, userID, domain.ChannelHistoryFilter{
ChannelID: channelID,
Limit: 1,
})
if err != nil {
if isChannelPreviewAccessError(err) {
continue
}
return domain.DialogList{}, err
}
dialog := dialogFromChannelView(view)
if len(history.Messages) > 0 {
top := history.Messages[0]
dialog.TopMessage = top.ID
dialog.TopMessageDate = top.Date
out.ChannelMessages = append(out.ChannelMessages, top)
}
out.Dialogs = append(out.Dialogs, dialog)
out.Channels = append(out.Channels, view.Channel)
out.Channels = append(out.Channels, history.Channels...)
out.Users = append(out.Users, history.Users...)
out.Count++
present[channelID] = struct{}{}
}
return out, nil
}
func isChannelPreviewAccessError(err error) bool {
return errors.Is(err, domain.ErrChannelPrivate) ||
errors.Is(err, domain.ErrChannelUserBanned) ||
errors.Is(err, domain.ErrChannelInvalid)
}
func dialogFromChannelView(view domain.ChannelView) domain.Dialog {
dialog := view.Dialog
return domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
ChannelLeft: view.Self.Status == domain.ChannelMemberLeft,
FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: view.Channel.Pts,
}
}
// SaveDraft stores or clears a cloud draft for one peer/topic.
// It returns whether the authoritative draft content changed. A repeated save
// with identical content does not refresh Date, invalidate read models, or
// force a durable update.
func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) (bool, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return false, nil
}
if err := validateDraft(draft); err != nil {
return false, err
}
if draft.Empty() {
changed, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID)
if err == nil && changed {
s.InvalidateDialog(userID, draft.Peer)
}
return changed, err
}
existing, found, err := s.dialogs.GetDraft(ctx, userID, draft.Peer, draft.TopMessageID)
if err != nil {
return false, err
}
if found && sameDialogDraftContent(existing, draft) {
return false, nil
}
if err := s.dialogs.SaveDraft(ctx, userID, draft); err != nil {
return false, err
}
s.InvalidateDialog(userID, draft.Peer)
return true, nil
}
func sameDialogDraftContent(a, b domain.DialogDraft) bool {
a.Date = 0
b.Date = 0
return reflect.DeepEqual(a, b)
}
// GetDraft 读取某会话当前云草稿draft_message 事件重放时按 peer 重载用)。
func (s *Service) GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return domain.DialogDraft{}, false, nil
}
if err := validateDraftKey(peer, topMessageID); err != nil {
return domain.DialogDraft{}, false, err
}
return s.dialogs.GetDraft(ctx, userID, peer, topMessageID)
}
// DeleteDraft clears one cloud draft.
func (s *Service) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return false, nil
}
if err := validateDraftKey(peer, topMessageID); err != nil {
return false, err
}
changed, err := s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
}
// ListDrafts returns bounded cloud drafts for messages.getAllDrafts.
func (s *Service) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return nil, nil
}
return s.dialogs.ListDrafts(ctx, userID, clampDraftLimit(limit))
}
// ClearDrafts deletes bounded cloud drafts for messages.clearAllDrafts.
func (s *Service) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return nil, nil
}
drafts, err := s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit))
if err == nil {
for _, draft := range drafts {
s.InvalidateDialog(userID, draft.Peer)
}
}
return drafts, err
}
// TogglePinned 置顶/取消置顶一条会话;置顶顺序在会话当前 folder 内分配,
// 返回 (changed, 该会话所在 folder_id) 供 updateDialogPinned.folder_id 使用。
// pin 时按 folder 校验上限(重复 pin 幂等放行),超限返回 ErrPinnedDialogsTooMuch。
func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) {
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, 0, nil
}
if pinned {
if err := s.checkPinnedLimit(ctx, userID, peer); err != nil {
return false, 0, err
}
}
switch peer.Type {
case domain.PeerTypeChannel:
if s.channels == nil {
return false, 0, nil
}
changed, folderID, err := s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned)
if err == nil && changed {
if pinned {
if err := s.promotePinnedDialog(ctx, userID, folderID, peer); err != nil {
return changed, folderID, err
}
}
s.InvalidateDialog(userID, peer)
}
return changed, folderID, err
default:
if s.dialogs == nil {
return false, 0, nil
}
changed, folderID, err := s.dialogs.SetPinned(ctx, userID, peer, pinned)
if err == nil && changed {
if pinned {
if err := s.promotePinnedDialog(ctx, userID, folderID, peer); err != nil {
return changed, folderID, err
}
}
s.InvalidateDialog(userID, peer)
}
return changed, folderID, err
}
}
func (s *Service) promotePinnedDialog(ctx context.Context, userID int64, folderID int, peer domain.Peer) error {
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
return nil
}
list, err := s.GetDialogs(ctx, userID, domain.DialogFilter{
PinnedOnly: true,
HasFolderID: true,
FolderID: folderID,
Limit: domain.PinnedDialogsLimit(folderID, true),
})
if err != nil {
return err
}
order := make([]domain.Peer, 0, len(list.Dialogs))
order = append(order, peer)
seen := map[domain.Peer]struct{}{peer: {}}
found := false
for _, dialog := range list.Dialogs {
if dialog.Peer == peer {
found = true
continue
}
if !dialog.Pinned {
continue
}
if _, ok := seen[dialog.Peer]; ok {
continue
}
seen[dialog.Peer] = struct{}{}
order = append(order, dialog.Peer)
}
if !found {
return nil
}
_, err = s.ReorderPinned(ctx, userID, folderID, order, false)
return err
}
func (s *Service) checkPinnedLimit(ctx context.Context, userID int64, peer domain.Peer) error {
current, err := s.GetPeerDialogs(ctx, userID, []domain.Peer{peer})
if err != nil {
return err
}
folderID := domain.DialogMainFolderID
for _, dialog := range current.Dialogs {
if dialog.Peer != peer {
continue
}
if dialog.Pinned {
// 重复 pin 幂等,不占新名额。
return nil
}
folderID = dialog.FolderID
break
}
premium := s.premium != nil && s.premium(ctx, userID)
limit := domain.PinnedDialogsLimit(folderID, premium)
pinnedList, err := s.GetDialogs(ctx, userID, domain.DialogFilter{
PinnedOnly: true,
HasFolderID: true,
FolderID: folderID,
Limit: limit,
})
if err != nil {
return err
}
if pinnedList.Count >= limit {
return domain.ErrPinnedDialogsTooMuch
}
return nil
}
// ToggleArchivePinned 置顶/取消置顶 archive folder 行本身
// toggleDialogPin(inputDialogPeerFolder)),返回是否变化。
func (s *Service) ToggleArchivePinned(ctx context.Context, userID int64, pinned bool) (bool, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return false, nil
}
changed, err := s.dialogs.SetArchivePinned(ctx, userID, pinned)
if err == nil && changed {
s.invalidateDialogListHashes(userID)
}
return changed, err
}
// ReorderPinned 重排指定 folder0 主列表/1 归档)内的置顶顺序;
// force 只清除该 folder 内不在 order 中的置顶,绝不跨 folder 误伤。
func (s *Service) ReorderPinned(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) {
if s == nil || userID == 0 {
return false, nil
}
changed := false
if s.dialogs != nil {
privateChanged, err := s.dialogs.ReorderPinned(ctx, userID, folderID, order, force)
if err != nil {
return false, err
}
if privateChanged {
changed = true
for _, peer := range order {
if peer.Type != domain.PeerTypeChannel {
s.InvalidateDialog(userID, peer)
}
}
}
}
if s.channels != nil {
channelChanged, err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, folderID, order, force)
if err != nil {
return false, err
}
if channelChanged {
changed = true
for _, peer := range order {
if peer.Type == domain.PeerTypeChannel {
s.InvalidateDialog(userID, peer)
}
}
}
}
if changed && force {
// force may unpin peers omitted from order; the store returns only a boolean,
// so flush the small peer-dialog snapshot cache to avoid stale omitted peers.
s.FlushReadModelCache()
}
return changed, nil
}
func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, nil
}
switch peer.Type {
case domain.PeerTypeChannel:
if s.channels == nil {
return false, nil
}
changed, err := s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
default:
if s.dialogs == nil {
return false, nil
}
changed, err := s.dialogs.SetUnreadMark(ctx, userID, peer, unread)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
}
}
func (s *Service) UnreadMarks(ctx context.Context, userID int64) ([]domain.Peer, error) {
if s == nil || userID == 0 {
return nil, nil
}
var out []domain.Peer
if s.dialogs != nil {
peers, err := s.dialogs.ListUnreadMarked(ctx, userID)
if err != nil {
return nil, err
}
out = append(out, peers...)
}
if s.channels != nil {
peers, err := s.channels.ListChannelUnreadMarked(ctx, userID)
if err != nil {
return nil, err
}
out = append(out, peers...)
}
return out, nil
}
func (s *Service) HidePeerSettingsBar(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, nil
}
changed, err := s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
}
func (s *Service) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, nil
}
return s.dialogs.PeerSettingsBarHidden(ctx, userID, peer)
}
func (s *Service) GetDialogFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return domain.DialogFolderList{}, nil
}
return s.dialogs.ListFolders(ctx, userID)
}
func (s *Service) SaveDialogFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error {
if s == nil || s.dialogs == nil || userID == 0 {
return nil
}
if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil {
return err
}
s.invalidateDialogListHashes(userID)
return nil
}
func (s *Service) DeleteDialogFolder(ctx context.Context, userID int64, folderID int) error {
if s == nil || s.dialogs == nil || userID == 0 {
return nil
}
if err := s.dialogs.DeleteFolder(ctx, userID, folderID); err != nil {
return err
}
s.invalidateDialogListHashes(userID)
return nil
}
func (s *Service) ReorderDialogFolders(ctx context.Context, userID int64, order []int) error {
if s == nil || s.dialogs == nil || userID == 0 {
return nil
}
if err := s.dialogs.ReorderFolders(ctx, userID, order); err != nil {
return err
}
s.invalidateDialogListHashes(userID)
return nil
}
func (s *Service) ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error {
if s == nil || s.dialogs == nil || userID == 0 {
return nil
}
return s.dialogs.SetFolderTagsEnabled(ctx, userID, enabled)
}
func (s *Service) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error {
if s == nil || userID == 0 || len(peers) == 0 {
return nil
}
privatePeers := make([]domain.FolderPeerUpdate, 0, len(peers))
channelPeers := make([]domain.FolderPeerUpdate, 0, len(peers))
for _, peer := range peers {
if peer.Peer.Type == domain.PeerTypeChannel {
channelPeers = append(channelPeers, peer)
} else {
privatePeers = append(privatePeers, peer)
}
}
if len(privatePeers) > 0 && s.dialogs != nil {
if err := s.dialogs.EditPeerFolders(ctx, userID, privatePeers); err != nil {
return err
}
for _, update := range privatePeers {
s.InvalidateDialog(userID, update.Peer)
}
}
if len(channelPeers) > 0 && s.channels != nil {
if err := s.channels.EditChannelPeerFolders(ctx, userID, channelPeers); err != nil {
return err
}
for _, update := range channelPeers {
s.InvalidateDialog(userID, update.Peer)
}
}
return nil
}
func (s *Service) attachDrafts(ctx context.Context, userID int64, list *domain.DialogList) error {
if s == nil || s.dialogs == nil || userID == 0 || list == nil || len(list.Dialogs) == 0 {
return nil
}
drafts, err := s.dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser)
if err != nil {
return err
}
if len(drafts) == 0 {
return nil
}
byPeer := make(map[domain.Peer]domain.DialogDraft, len(drafts))
for _, draft := range drafts {
if draft.TopMessageID != 0 {
continue
}
byPeer[draft.Peer] = cloneDraft(draft)
}
if len(byPeer) == 0 {
return nil
}
attached := false
for i := range list.Dialogs {
draft, ok := byPeer[list.Dialogs[i].Peer]
if !ok {
continue
}
d := cloneDraft(draft)
list.Dialogs[i].Draft = &d
attached = true
}
if attached {
list.Hash = dialogHashWithDrafts(list.Hash, list.Dialogs)
}
return nil
}
func (s *Service) projectDialogUsers(ctx context.Context, userID int64, list *domain.DialogList) error {
if s == nil || s.projector == nil || list == nil || len(list.Users) == 0 {
return nil
}
users, err := s.projector.ForViewer(ctx, userID, list.Users)
if err != nil {
return err
}
list.Users = users
return nil
}
func validateDraft(draft domain.DialogDraft) error {
if err := validateDraftKey(draft.Peer, draft.TopMessageID); err != nil {
return err
}
if len(draft.Entities) > domain.MaxMessageEntityCount {
return domain.ErrChannelInvalid
}
if utf8.RuneCountInString(draft.Message) > domain.MaxMessageTextLength {
return domain.ErrChannelInvalid
}
if domain.ValidateMessageReplyBounds(draft.ReplyTo) != nil {
return domain.ErrReplyMessageIDInvalid
}
return nil
}
func validateDraftKey(peer domain.Peer, topMessageID int) error {
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
return domain.ErrChannelInvalid
}
if topMessageID < 0 || topMessageID > domain.MaxMessageBoxID {
return domain.ErrReplyMessageIDInvalid
}
return nil
}
func clampDraftLimit(limit int) int {
if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
return domain.MaxDialogDraftsPerUser
}
return limit
}
func cloneDraft(draft domain.DialogDraft) domain.DialogDraft {
draft.Entities = append([]domain.MessageEntity(nil), draft.Entities...)
if draft.ReplyTo != nil {
reply := *draft.ReplyTo
reply.QuoteEntities = append([]domain.MessageEntity(nil), draft.ReplyTo.QuoteEntities...)
draft.ReplyTo = &reply
}
if draft.WebPage != nil {
webpage := *draft.WebPage
draft.WebPage = &webpage
}
draft.RichMessage = cloneRichMessage(draft.RichMessage)
return draft
}
func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 {
if len(dialogs) == 0 {
return base
}
h := fnv.New64a()
var buf [48]byte
binary.LittleEndian.PutUint64(buf[:8], uint64(base))
_, _ = h.Write(buf[:8])
for _, d := range dialogs {
if d.Draft == nil {
continue
}
binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID))
binary.LittleEndian.PutUint32(buf[8:12], uint32(d.Draft.TopMessageID))
binary.LittleEndian.PutUint32(buf[12:16], uint32(d.Draft.Date))
binary.LittleEndian.PutUint64(buf[16:24], uint64(len(d.Draft.Message)))
if d.Draft.NoWebpage {
buf[24] = 1
} else {
buf[24] = 0
}
if d.Draft.InvertMedia {
buf[25] = 1
} else {
buf[25] = 0
}
binary.LittleEndian.PutUint64(buf[26:34], uint64(len(d.Draft.Entities)))
binary.LittleEndian.PutUint64(buf[34:42], uint64(d.Draft.Effect))
_, _ = h.Write(buf[:])
_, _ = h.Write([]byte(d.Draft.Message))
if d.Draft.WebPage != nil {
_, _ = h.Write([]byte(d.Draft.WebPage.URL))
}
writeDraftRichHash(h, buf[:], d.Draft.RichMessage)
}
return int64(h.Sum64())
}
func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
if m == nil {
return nil
}
clone := *m
clone.Blocks = append([]byte(nil), m.Blocks...)
clone.Photos = append([]domain.Photo(nil), m.Photos...)
clone.Documents = append([]domain.Document(nil), m.Documents...)
clone.BotAPIProjection = append([]byte(nil), m.BotAPIProjection...)
return &clone
}
func writeDraftRichHash(h interface{ Write([]byte) (int, error) }, buf []byte, rich *domain.MessageRichMessage) {
if rich.IsZero() {
return
}
if rich.Rtl {
buf[0] = 1
} else {
buf[0] = 0
}
if rich.Part {
buf[1] = 1
} else {
buf[1] = 0
}
binary.LittleEndian.PutUint64(buf[2:10], uint64(len(rich.Blocks)))
binary.LittleEndian.PutUint64(buf[10:18], uint64(len(rich.Photos)))
binary.LittleEndian.PutUint64(buf[18:26], uint64(len(rich.Documents)))
_, _ = h.Write(buf[:26])
_, _ = h.Write(rich.Blocks)
for _, photo := range rich.Photos {
binary.LittleEndian.PutUint64(buf[:8], uint64(photo.ID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(photo.AccessHash))
_, _ = h.Write(buf[:16])
}
for _, document := range rich.Documents {
binary.LittleEndian.PutUint64(buf[:8], uint64(document.ID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(document.AccessHash))
_, _ = h.Write(buf[:16])
}
}
func mergeDialogLists(out, in domain.DialogList) domain.DialogList {
out.Dialogs = append(out.Dialogs, in.Dialogs...)
out.Messages = append(out.Messages, in.Messages...)
out.ChannelMessages = append(out.ChannelMessages, in.ChannelMessages...)
out.Users = append(out.Users, in.Users...)
out.Channels = append(out.Channels, in.Channels...)
out.Count += in.Count
out.Hash ^= in.Hash
return out
}
func mergeChannelDialogs(out domain.DialogList, in domain.ChannelDialogList) domain.DialogList {
out.Dialogs = append(out.Dialogs, in.Dialogs...)
out.ChannelMessages = append(out.ChannelMessages, in.Messages...)
out.Channels = append(out.Channels, in.Channels...)
out.Users = append(out.Users, in.Users...)
out.Count += in.Count
out.Hash ^= in.Hash
return out
}
func sortDialogList(dialogs []domain.Dialog) {
sort.SliceStable(dialogs, func(i, j int) bool {
if dialogs[i].Pinned != dialogs[j].Pinned {
return dialogs[i].Pinned
}
if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder {
return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder
}
if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate {
return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate
}
if dialogs[i].TopMessage != dialogs[j].TopMessage {
return dialogs[i].TopMessage > dialogs[j].TopMessage
}
return dialogs[i].Peer.ID > dialogs[j].Peer.ID
})
}
func filterPrivateMessagesByPeer(messages []domain.Message, keep map[domain.Peer]struct{}) []domain.Message {
out := messages[:0]
for _, msg := range messages {
if _, ok := keep[msg.Peer]; ok {
out = append(out, msg)
}
}
return out
}
func filterChannelMessagesByPeer(messages []domain.ChannelMessage, keep map[domain.Peer]struct{}) []domain.ChannelMessage {
out := messages[:0]
for _, msg := range messages {
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID}
if _, ok := keep[peer]; ok {
out = append(out, msg)
}
}
return out
}
func filterChannelsByPeer(channels []domain.Channel, keep map[domain.Peer]struct{}) []domain.Channel {
out := channels[:0]
for _, ch := range channels {
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}
if _, ok := keep[peer]; ok {
out = append(out, ch)
}
}
return out
}