package memory import ( "context" "errors" "sort" "telesrv/internal/domain" ) func (s *ChannelStore) ListChannelDialogs(_ context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) { if viewerUserID == 0 { return domain.ChannelDialogList{}, nil } limit := filter.Limit if limit <= 0 || limit > 100 { limit = 100 } s.mu.RLock() defer s.mu.RUnlock() channelIDs := make([]int64, 0, len(s.dialogs[viewerUserID])) seen := make(map[int64]struct{}, len(s.dialogs[viewerUserID])) for channelID := range s.dialogs[viewerUserID] { channelIDs = append(channelIDs, channelID) seen[channelID] = struct{}{} } for channelID, members := range s.members { if _, ok := seen[channelID]; ok { continue } if member, ok := members[viewerUserID]; ok && member.Status == domain.ChannelMemberActive { channelIDs = append(channelIDs, channelID) seen[channelID] = struct{}{} } } syntheticMembers := make(map[int64]domain.ChannelMember) for channelID, channel := range s.channels { if _, ok := seen[channelID]; ok { continue } if !channel.Monoforum || channel.LinkedMonoforumID == 0 || channel.Deleted { continue } parentMember, ok := s.members[channel.LinkedMonoforumID][viewerUserID] if !ok || parentMember.Status != domain.ChannelMemberActive || !isChannelAdmin(parentMember) { continue } channelIDs = append(channelIDs, channelID) seen[channelID] = struct{}{} syntheticMembers[channelID] = syntheticMonoforumAdminMember(channel, parentMember) } items := make([]domain.Dialog, 0, len(channelIDs)) for _, channelID := range channelIDs { channel, ok := s.channels[channelID] if !ok || channel.Deleted { continue } member, synthetic := syntheticMembers[channelID] if !synthetic { if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { continue } member = s.members[channelID][viewerUserID] } if member.Status != domain.ChannelMemberActive { continue } item := channelDialogToDialog(s.dialogForMemberLocked(viewerUserID, channel, member), channel.Pts, member.Status) if !channelDialogMatchesFilter(item, channel, filter) { continue } items = append(items, item) } sort.Slice(items, func(i, j int) bool { if items[i].Pinned != items[j].Pinned { return items[i].Pinned } if items[i].PinnedOrder != items[j].PinnedOrder { return items[i].PinnedOrder > items[j].PinnedOrder } if items[i].TopMessageDate != items[j].TopMessageDate { return items[i].TopMessageDate > items[j].TopMessageDate } if items[i].TopMessage != items[j].TopMessage { return items[i].TopMessage > items[j].TopMessage } return items[i].Peer.ID > items[j].Peer.ID }) out := domain.ChannelDialogList{Count: len(items)} for _, dialog := range items { if len(out.Dialogs) >= limit { break } out.Dialogs = append(out.Dialogs, dialog) channel := s.channels[dialog.Peer.ID] out.Channels = append(out.Channels, channel) if msg, ok := s.findMessageLocked(dialog.Peer.ID, dialog.TopMessage); ok && !msg.Deleted { out.Messages = append(out.Messages, cloneChannelMessage(msg)) } } // 与 PG 同因:getDialogs top message 按 viewer 补未读提及标志。 s.populateChannelMessageUnreadFlagsLocked(viewerUserID, out.Messages) return out, nil } func (s *ChannelStore) GetChannelDialogs(_ context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) { if viewerUserID == 0 || len(channelIDs) == 0 { return domain.ChannelDialogList{}, nil } s.mu.RLock() defer s.mu.RUnlock() out := domain.ChannelDialogList{} seen := make(map[int64]struct{}, len(channelIDs)) for _, channelID := range channelIDs { if channelID == 0 { continue } if _, ok := seen[channelID]; ok { continue } seen[channelID] = struct{}{} channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID) if err != nil { if !errors.Is(err, domain.ErrChannelPrivate) && !errors.Is(err, domain.ErrChannelInvalid) { continue } var ok bool channel, ok = s.channels[channelID] if !ok || channel.Deleted || !channel.Monoforum || channel.LinkedMonoforumID == 0 { continue } parentMember, ok := s.members[channel.LinkedMonoforumID][viewerUserID] if !ok || parentMember.Status != domain.ChannelMemberActive || !isChannelAdmin(parentMember) { continue } member = syntheticMonoforumAdminMember(channel, parentMember) } if member.Status != domain.ChannelMemberActive { continue } dialog := channelDialogToDialog(s.dialogForMemberLocked(viewerUserID, channel, member), channel.Pts, member.Status) out.Dialogs = append(out.Dialogs, dialog) out.Channels = append(out.Channels, channel) // 与 postgres GetChannelDialogs 一致:monoforum 私信会话须同批下发母广播频道,客户端 // 才能 resolve linked_monoforum_id 并派生 MonoforumAdmin 渲染 Direct-Messages 容器;否则 // 退化成普通 megagroup。到达此处的 monoforum 必为管理员预览(非管理员在 // channelForViewerLocked 已返 ErrChannelPrivate 被 skip),不泄漏给无关用户。 if channel.Monoforum && channel.LinkedMonoforumID != 0 { if parent, ok := s.channels[channel.LinkedMonoforumID]; ok && !parent.Deleted { out.Channels = append(out.Channels, cloneChannel(parent)) } } if msg, ok := s.findMessageLocked(channelID, dialog.TopMessage); ok && !msg.Deleted { out.Messages = append(out.Messages, cloneChannelMessage(msg)) } } out.Count = len(out.Dialogs) s.populateChannelMessageUnreadFlagsLocked(viewerUserID, out.Messages) return out, nil } func (s *ChannelStore) ListCommonChannels(_ context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) { if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 { return domain.CommonChannelsResult{}, domain.ErrChannelInvalid } limit := req.Limit if limit <= 0 || limit > domain.MaxCommonChannelsLimit { limit = domain.MaxCommonChannelsLimit } s.mu.RLock() defer s.mu.RUnlock() ids := make([]int64, 0) for channelID, members := range s.members { self, selfOK := members[req.UserID] target, targetOK := members[req.TargetUserID] if !selfOK || !targetOK || self.Status != domain.ChannelMemberActive || target.Status != domain.ChannelMemberActive { continue } channel, ok := s.channels[channelID] if !ok || channel.Deleted || !channel.Megagroup || channel.Broadcast { continue } ids = append(ids, channelID) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) out := domain.CommonChannelsResult{Count: len(ids)} if req.CountOnly { return out, nil } for _, channelID := range ids { if req.MaxID > 0 && channelID <= req.MaxID { continue } out.Channels = append(out.Channels, cloneChannel(s.channels[channelID])) if len(out.Channels) >= limit { break } } return out, nil } func (s *ChannelStore) ListLeftChannels(_ context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) { if userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset { return domain.LeftChannelsResult{}, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxLeftChannelsLimit { limit = domain.MaxLeftChannelsLimit } s.mu.RLock() defer s.mu.RUnlock() all := make([]domain.LeftChannel, 0) for channelID, members := range s.members { member, ok := members[userID] if !ok || member.Status != domain.ChannelMemberLeft { continue } channel, ok := s.channels[channelID] if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) { continue } all = append(all, domain.LeftChannel{ Channel: cloneChannel(channel), Self: member, }) } sort.Slice(all, func(i, j int) bool { if all[i].Self.LeftAt != all[j].Self.LeftAt { return all[i].Self.LeftAt > all[j].Self.LeftAt } return all[i].Channel.ID > all[j].Channel.ID }) out := domain.LeftChannelsResult{Count: len(all)} if offset >= len(all) { return out, nil } end := offset + limit if end > len(all) { end = len(all) } out.Channels = append(out.Channels, all[offset:end]...) return out, nil } func (s *ChannelStore) ListInactiveChannels(_ context.Context, userID int64, limit int) (domain.ChannelDialogList, error) { if userID == 0 { return domain.ChannelDialogList{}, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxInactiveChannelsLimit { limit = domain.MaxInactiveChannelsLimit } s.mu.RLock() defer s.mu.RUnlock() type item struct { channel domain.Channel dialog domain.Dialog } items := make([]item, 0, limit) for channelID, members := range s.members { member, ok := members[userID] if !ok || member.Status != domain.ChannelMemberActive { continue } channel, ok := s.channels[channelID] if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) { continue } if _, err := s.channelForMemberLocked(userID, channelID); err != nil { continue } dialog := channelDialogToDialog(s.dialogForUserLocked(userID, channel), channel.Pts, member.Status) dialog.TopMessageDate = inactiveChannelDate(dialog, channel, member) items = append(items, item{channel: cloneChannel(channel), dialog: dialog}) } sort.Slice(items, func(i, j int) bool { if items[i].dialog.TopMessageDate != items[j].dialog.TopMessageDate { return items[i].dialog.TopMessageDate < items[j].dialog.TopMessageDate } if items[i].dialog.TopMessage != items[j].dialog.TopMessage { return items[i].dialog.TopMessage < items[j].dialog.TopMessage } return items[i].channel.ID < items[j].channel.ID }) if len(items) > limit { items = items[:limit] } out := domain.ChannelDialogList{Count: len(items)} for _, item := range items { out.Dialogs = append(out.Dialogs, item.dialog) out.Channels = append(out.Channels, item.channel) } return out, nil } func (s *ChannelStore) SetChannelDialogPinned(_ context.Context, userID, channelID int64, pinned bool) (bool, int, error) { if userID == 0 || channelID == 0 { return false, 0, nil } s.mu.Lock() defer s.mu.Unlock() channel, err := s.channelForMemberLocked(userID, channelID) if err != nil { return false, 0, nil } dialog := s.dialogForUserLocked(userID, channel) targetFolderID := dialog.FolderID // order 仅在目标会话所在 folder 内分配;memory 双 store 互不可见, // 与 postgres 跨表统一 order 空间相比此处只看 channel 表(reorder 会统一重排)。 nextOrder := 1 for _, d := range s.dialogs[userID] { if d.Pinned && d.FolderID == targetFolderID && d.PinnedOrder >= nextOrder { nextOrder = d.PinnedOrder + 1 } } changed := dialog.Pinned != pinned || (pinned && dialog.PinnedOrder == 0) dialog.Pinned = pinned if pinned { if dialog.PinnedOrder == 0 { dialog.PinnedOrder = nextOrder } } else { dialog.PinnedOrder = 0 } if s.dialogs[userID] == nil { s.dialogs[userID] = make(map[int64]domain.ChannelDialog) } s.dialogs[userID][channelID] = dialog return changed, targetFolderID, nil } func (s *ChannelStore) ReorderChannelPinnedDialogs(_ context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) { if userID == 0 { return false, nil } positions := make(map[int64]int, len(order)) for i, peer := range order { if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { continue } if _, ok := positions[peer.ID]; ok { continue } positions[peer.ID] = len(order) - i } s.mu.Lock() defer s.mu.Unlock() changed := false for channelID, dialog := range s.dialogs[userID] { if dialog.FolderID != folderID { continue } if pos, ok := positions[channelID]; ok { if !dialog.Pinned || dialog.PinnedOrder != pos { changed = true } dialog.Pinned = true dialog.PinnedOrder = pos s.dialogs[userID][channelID] = dialog continue } if force && dialog.Pinned { changed = true dialog.Pinned = false dialog.PinnedOrder = 0 s.dialogs[userID][channelID] = dialog } } return changed, nil } func (s *ChannelStore) EditChannelPeerFolders(_ context.Context, userID int64, peers []domain.FolderPeerUpdate) error { if userID == 0 || len(peers) == 0 { return nil } updates := make(map[int64]int, len(peers)) for _, item := range peers { if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { continue } if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID { continue } updates[item.Peer.ID] = item.FolderID } if len(updates) == 0 { return nil } s.mu.Lock() defer s.mu.Unlock() for channelID, folderID := range updates { channel, err := s.channelForMemberLocked(userID, channelID) if err != nil { continue } dialog := s.dialogForUserLocked(userID, channel) // 换 folder 时清 pinned:与私聊 EditPeerFolders 一致,TDesktop // 在归档/还原时本地无条件 unpin,服务端保留旧 pin 会造成状态漂移。 if dialog.FolderID != folderID { dialog.Pinned = false dialog.PinnedOrder = 0 } dialog.FolderID = folderID if s.dialogs[userID] == nil { s.dialogs[userID] = make(map[int64]domain.ChannelDialog) } s.dialogs[userID][channelID] = dialog } return nil } func (s *ChannelStore) CountChannelArchiveUnread(_ context.Context, userID int64) (int, int, error) { if userID == 0 { return 0, 0, nil } s.mu.RLock() defer s.mu.RUnlock() peers, messages := 0, 0 for channelID, dialog := range s.dialogs[userID] { if dialog.FolderID != domain.DialogArchiveFolderID { continue } // 与 postgres 一致:仅统计 active 成员的会话。 if _, err := s.channelForMemberLocked(userID, channelID); err != nil { continue } if dialog.UnreadCount > 0 || dialog.UnreadMark { peers++ } messages += dialog.UnreadCount } return peers, messages, nil } func (s *ChannelStore) upsertChannelDialogLocked(userID int64, channel domain.Channel, top domain.ChannelMessage, selfAction bool) { if s.dialogs[userID] == nil { s.dialogs[userID] = make(map[int64]domain.ChannelDialog) } dialog := s.dialogs[userID][channel.ID] dialog.UserID = userID dialog.ChannelID = channel.ID dialog.TopMessageID = s.visibleTopMessageIDLocked(userID, channel) if top.ID != 0 { dialog.TopMessageDate = top.Date } member := s.members[channel.ID][userID] if member.ReadInboxMaxID > dialog.ReadInboxMaxID { dialog.ReadInboxMaxID = member.ReadInboxMaxID } if selfAction { // 只推进 inbox:自己动作产生的消息不算自己的未读。outbox 回执水位 // 表示"已被其他成员读到的最大 ID",绝不随自己的发送/动作推进, // 否则同账号另一设备会把自己的消息渲染成对端已读。 if channel.TopMessageID > dialog.ReadInboxMaxID { dialog.ReadInboxMaxID = channel.TopMessageID } // 发送方向清手动未读标记,对齐 postgres 发送路径双表清除。 dialog.UnreadMark = false } dialog.UnreadCount = s.channelUnreadCountLocked(userID, channel.ID, dialog.ReadInboxMaxID, dialog.TopMessageID) dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(userID, channel.ID, 0) dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channel.ID, 0) s.dialogs[userID][channel.ID] = dialog } func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int, memberStatus domain.ChannelMemberStatus) domain.Dialog { return domain.Dialog{ Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID}, // 非成员预览(publicPreviewMember/被踢)须标记 ChannelLeft,客户端据此把频道渲染为只读 left 预览。 ChannelLeft: memberStatus == 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: channelPts, } } func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { topMessageID := channel.TopMessageID if topMessageID <= member.AvailableMinID { topMessageID = 0 } return domain.ChannelDialog{ UserID: userID, ChannelID: channel.ID, TopMessageID: topMessageID, TopMessageDate: channel.Date, ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID), ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID), } } func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool { if filter.HasFolderID { if filter.FolderID < domain.DialogCustomFolderMinID { if dialog.FolderID != filter.FolderID { return false } } else if filter.Folder == nil { return false } } else if dialog.FolderID != domain.DialogMainFolderID { // 不带 folder_id 视为主列表(folder 0),与私聊侧/官方语义一致。 return false } if filter.PinnedOnly && !dialog.Pinned { return false } if filter.ExcludePinned && dialog.Pinned { return false } if !channelDialogAfterOffset(dialog, filter) { return false } if filter.Folder == nil { return true } folder := filter.Folder if peerInFolderList(dialog.Peer, folder.ExcludePeers) { return false } if folder.ExcludeRead && dialog.UnreadCount == 0 && !dialog.UnreadMark { return false } if folder.ExcludeArchived && dialog.FolderID == domain.DialogArchiveFolderID { return false } if peerInFolderList(dialog.Peer, folder.PinnedPeers) || peerInFolderList(dialog.Peer, folder.IncludePeers) { return true } if channel.Megagroup && folder.Groups { return true } if channel.Broadcast && folder.Broadcasts { return true } // 群/频道只能经 Groups/Broadcasts 开关或显式 include/pinned 进入自定义文件夹; // 仅勾 Contacts/NonContacts/Bots 的文件夹不含任何群/频道(与 postgres 对齐)。 return false } func channelDialogAfterOffset(dialog domain.Dialog, filter domain.DialogFilter) bool { if filter.OffsetDate <= 0 && filter.OffsetID <= 0 { if filter.HasOffsetPeer && filter.OffsetPeer == dialog.Peer { return false } return true } if filter.OffsetDate > 0 { if dialog.TopMessageDate != filter.OffsetDate { return dialog.TopMessageDate < filter.OffsetDate } if filter.OffsetID <= 0 { return false } if dialog.TopMessage != filter.OffsetID { return dialog.TopMessage < filter.OffsetID } if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { return dialog.Peer.ID < filter.OffsetPeer.ID } return false } if dialog.TopMessage != filter.OffsetID { return dialog.TopMessage < filter.OffsetID } if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { return dialog.Peer.ID < filter.OffsetPeer.ID } return false } func peerInFolderList(peer domain.Peer, items []domain.DialogFolderPeer) bool { for _, item := range items { if item.Peer == peer { return true } } return false }