package memory import ( "context" "crypto/rand" "encoding/binary" "errors" "sort" "strings" "telesrv/internal/domain" ) func (s *ChannelStore) SaveChannelDefaultSendAs(_ context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ChannelView{}, domain.ErrChannelInvalid } if req.SendAs != nil && req.SendAs.Type != domain.PeerTypeUser && req.SendAs.Type != domain.PeerTypeChannel { return domain.ChannelView{}, domain.ErrChannelInvalid } s.mu.Lock() defer s.mu.Unlock() channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) if err != nil { return domain.ChannelView{}, err } dialog := s.dialogForUserLocked(req.UserID, channel) if req.SendAs != nil { p := *req.SendAs dialog.DefaultSendAs = &p } else { dialog.DefaultSendAs = nil } if s.dialogs[req.UserID] == nil { s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) } s.dialogs[req.UserID][req.ChannelID] = dialog member := s.members[req.ChannelID][req.UserID] return domain.ChannelView{Channel: cloneChannel(channel), Self: member, Dialog: dialog}, nil } func (s *ChannelStore) DeleteChannel(_ context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.DeleteChannelResult{}, domain.ErrChannelInvalid } s.mu.Lock() defer s.mu.Unlock() channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) if err != nil { return domain.DeleteChannelResult{}, err } member := s.members[req.ChannelID][req.UserID] if member.Role != domain.ChannelRoleCreator { return domain.DeleteChannelResult{}, domain.ErrChannelAdminRequired } recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) channel.Deleted = true channel.Username = "" s.channels[req.ChannelID] = channel // 连带软删关联 monoforum(频道私信容器),与 PG store 等价:仅当 counterpart 是 monoforum 时级联, // 防止删 mono 反向误删真实母频道。不级联会留下指向已删父频道的孤儿 mono。 var linkedMono *domain.Channel if channel.LinkedMonoforumID != 0 { if mono, ok := s.channels[channel.LinkedMonoforumID]; ok && mono.Monoforum && !mono.Deleted { mono.Deleted = true mono.Username = "" s.channels[mono.ID] = mono clone := cloneChannel(mono) linkedMono = &clone } } return domain.DeleteChannelResult{Channel: channel, Recipients: recipients, LinkedMonoforum: linkedMono}, nil } func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) { if viewerUserID == 0 { return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxPublicChannelSearchLimit { limit = domain.MaxPublicChannelSearchLimit } query = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(query, "@"))) if query == "" { return domain.PublicChannelSearchResult{}, nil } s.mu.RLock() registry := s.usernameRegistry s.mu.RUnlock() var usernameMatches map[int64]int if registry != nil { usernameMatches = registry.activeUsernameMatches(query, domain.PeerTypeChannel) } s.mu.RLock() defer s.mu.RUnlock() type item struct { channel domain.Channel rank int } items := make([]item, 0, limit) for channelID, channel := range s.channels { rank, ok := publicChannelSearchRank(channel, query) if usernameRank, matched := usernameMatches[channelID]; matched && !channel.Deleted && (channel.Broadcast || channel.Megagroup) && (!ok || usernameRank < rank) { rank, ok = usernameRank, true } if !ok { continue } member, joined := s.members[channelID][viewerUserID] joined = joined && member.Status == domain.ChannelMemberActive if joined { continue } items = append(items, item{ channel: cloneChannel(channel), rank: rank, }) } sort.SliceStable(items, func(i, j int) bool { if items[i].rank != items[j].rank { return items[i].rank < items[j].rank } if items[i].channel.ParticipantsCount != items[j].channel.ParticipantsCount { return items[i].channel.ParticipantsCount > items[j].channel.ParticipantsCount } if items[i].channel.Date != items[j].channel.Date { return items[i].channel.Date > items[j].channel.Date } return items[i].channel.ID > items[j].channel.ID }) out := domain.PublicChannelSearchResult{} for _, item := range items { if len(out.Results) >= limit { break } out.Results = append(out.Results, item.channel) } return out, nil } func (s *ChannelStore) inviteByChannelHashLocked(channelID int64, hash string) (domain.ChannelInvite, error) { hash = strings.TrimSpace(hash) if hash == "" { return domain.ChannelInvite{}, domain.ErrInviteHashEmpty } invite, ok := s.invites[hash] if !ok || invite.ChannelID != channelID { return domain.ChannelInvite{}, domain.ErrInviteHashInvalid } return invite, nil } func (s *ChannelStore) inviteByIDLocked(channelID, inviteID int64) (domain.ChannelInvite, error) { for _, invite := range s.invites { if invite.ChannelID == channelID && invite.InviteID == inviteID { return invite, nil } } return domain.ChannelInvite{}, domain.ErrInviteHashInvalid } func (s *ChannelStore) SearchPublicPosts(_ context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) { query := strings.ToLower(strings.TrimSpace(req.Query)) hashtag := strings.ToLower(strings.TrimSpace(req.Hashtag)) if (query == "") == (hashtag == "") { return domain.ChannelHistory{}, domain.ErrChannelInvalid } if req.Limit <= 0 || req.Limit > domain.MaxChannelSearchPostsLimit { req.Limit = domain.MaxChannelSearchPostsLimit } type hit struct { channel domain.Channel message domain.ChannelMessage } s.mu.RLock() defer s.mu.RUnlock() hits := make([]hit, 0, req.Limit+1) for channelID, channel := range s.channels { if channel.Deleted || strings.TrimSpace(channel.Username) == "" { continue } for _, msg := range s.messages[channelID] { if msg.Deleted || strings.TrimSpace(msg.Body) == "" { continue } if !channelSearchPostAfterCursor(msg, req) { continue } body := strings.ToLower(msg.Body) if query != "" && !strings.Contains(body, query) { continue } if hashtag != "" && !strings.Contains(body, "#"+hashtag) { continue } hits = append(hits, hit{channel: channel, message: cloneChannelMessage(msg)}) } } sort.Slice(hits, func(i, j int) bool { a, b := hits[i].message, hits[j].message if a.Date != b.Date { return a.Date > b.Date } if a.ChannelID != b.ChannelID { return a.ChannelID > b.ChannelID } return a.ID > b.ID }) out := domain.ChannelHistory{Count: len(hits)} if out.Count > req.Limit { out.Count = req.Limit + 1 hits = hits[:req.Limit] } channelSeen := make(map[int64]struct{}, len(hits)) for _, h := range hits { out.Messages = append(out.Messages, h.message) if _, ok := channelSeen[h.channel.ID]; ok { continue } channelSeen[h.channel.ID] = struct{}{} out.Channels = append(out.Channels, h.channel) } s.populateChannelMessagesReactionsLocked(viewerUserID, out.Channels, out.Messages) return out, nil } func channelSearchPostAfterCursor(msg domain.ChannelMessage, req domain.ChannelSearchPostsRequest) bool { if req.OffsetRate <= 0 && req.OffsetChannelID <= 0 && req.OffsetID <= 0 { return true } if req.OffsetRate > 0 { if msg.Date < req.OffsetRate { return true } if msg.Date > req.OffsetRate { return false } } if req.OffsetChannelID > 0 { if msg.ChannelID < req.OffsetChannelID { return true } if msg.ChannelID > req.OffsetChannelID { return false } } if req.OffsetID > 0 { return msg.ID < req.OffsetID } return false } func channelGlobalSearchAfterCursor(msg domain.ChannelMessage, req domain.ChannelGlobalSearchRequest) bool { if req.OffsetRate <= 0 && req.OffsetChannelID <= 0 && req.OffsetID <= 0 { return true } if req.OffsetRate > 0 { if msg.Date < req.OffsetRate { return true } if msg.Date > req.OffsetRate { return false } } if req.OffsetChannelID > 0 { if msg.ChannelID < req.OffsetChannelID { return true } if msg.ChannelID > req.OffsetChannelID { return false } } if req.OffsetID > 0 { return msg.ID < req.OffsetID } return false } func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { s.mu.RLock() defer s.mu.RUnlock() if userID == 0 || afterChannelID < 0 { return nil, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { limit = domain.MaxSynchronousChannelDialogFanout } out := make([]int64, 0, limit) for channelID, members := range s.members { if channelID <= afterChannelID { continue } channel, ok := s.channels[channelID] if !ok || channel.Deleted { continue } member, ok := members[userID] if !ok || member.Status != domain.ChannelMemberActive { continue } out = append(out, channelID) } for channelID, channel := range s.channels { if channelID <= afterChannelID || !s.monoforumVisibleToUserLocked(channel, userID) || containsInt64(out, channelID) { continue } out = append(out, channelID) } sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) if len(out) > limit { out = out[:limit] } return out, nil } func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) { s.mu.RLock() defer s.mu.RUnlock() if userID == 0 || sinceDate <= 0 || afterChannelID < 0 { return nil, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { limit = domain.MaxChannelDifferenceLimit } out := make([]domain.DirtyChannel, 0, limit) for channelID, members := range s.members { if channelID <= afterChannelID { continue } channel, ok := s.channels[channelID] if !ok || channel.Deleted { continue } member, ok := members[userID] if !ok || member.Status != domain.ChannelMemberActive { continue } item := domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts} checkpoint := s.channelUpdateCheckpointLocked(channelID, channel) if checkpoint.LatestEventDate > sinceDate { item.ChannelUpdatesDirty = true } if clearDate := s.historyClearDates[channelID][userID]; clearDate >= sinceDate && member.HistoryClearAnchorID > 0 && member.HistoryClearAnchorID == member.AvailableMinID { item.AvailableMinID = member.AvailableMinID item.HistoryClearDate = clearDate } if item.ChannelUpdatesDirty || item.AvailableMinID > 0 { out = append(out, item) } } for channelID, channel := range s.channels { if channelID <= afterChannelID || !s.monoforumVisibleToUserLocked(channel, userID) { continue } checkpoint := s.channelUpdateCheckpointLocked(channelID, channel) if checkpoint.LatestEventDate > sinceDate { found := false for _, item := range out { if item.ChannelID == channelID { found = true break } } if !found { out = append(out, domain.DirtyChannel{ ChannelID: channelID, Pts: channel.Pts, ChannelUpdatesDirty: true, }) } } } sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID }) if len(out) > limit { out = out[:limit] } return out, nil } func (s *ChannelStore) monoforumVisibleToUserLocked(mono domain.Channel, userID int64) bool { if userID == 0 || mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 { return false } parent, ok := s.channels[mono.LinkedMonoforumID] if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != mono.ID { return false } if member, ok := s.members[parent.ID][userID]; ok && member.CanManageDirectMessages() { return true } for _, msg := range s.messages[mono.ID] { if !msg.Deleted && msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { return true } } return false } func containsInt64(items []int64, target int64) bool { for _, item := range items { if item == target { return true } } return false } func (s *ChannelStore) nextChannelIDLocked() int64 { id := s.nextID s.nextID++ return id } func (s *ChannelStore) nextAccessHashLocked() int64 { hash := s.nextHash s.nextHash += 17 return hash } func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, bool, error) { channel, member, err := s.channelAndMemberLocked(userID, channelID) if err == nil { return channel, member, false, nil } if !errors.Is(err, domain.ErrChannelPrivate) { return domain.Channel{}, domain.ChannelMember{}, false, err } channel, ok := s.channels[channelID] if !ok || channel.Deleted { return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelInvalid } existing, found := s.members[channelID][userID] if found && (existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages) { return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelUserBanned } if guest, ok, guestErr := s.linkedDiscussionGuestLocked(userID, channel); guestErr != nil { return domain.Channel{}, domain.ChannelMember{}, false, guestErr } else if ok { return channel, guest, true, nil } if channel.Monoforum && channel.LinkedMonoforumID != 0 { parentMember, ok := s.members[channel.LinkedMonoforumID][userID] if ok && parentMember.CanManageDirectMessages() { return channel, syntheticMonoforumAdminMember(channel, parentMember), true, nil } parent, ok := s.channels[channel.LinkedMonoforumID] if ok && !parent.Deleted && parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == channel.ID { return channel, syntheticMonoforumUserMember(channel, userID), true, nil } } if !s.publicPreviewableChannelLocked(channel) { return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate } return channel, publicPreviewMember(channel, userID, existing, found), true, nil } // channelMessageVisibleToViewerLocked applies the message-level half of synthetic monoforum // access. The channel shell is visible without a channel_members row, but a subscriber may only // address messages in saved_peer=self; managers may address every subscriber sub-dialog. func channelMessageVisibleToViewerLocked(channel domain.Channel, member domain.ChannelMember, viewerUserID int64, msg domain.ChannelMessage) bool { if !channel.Monoforum { return true } if member.CanManageDirectMessages() { return true } return msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) } func (s *ChannelStore) dialogForUserLocked(userID int64, channel domain.Channel) domain.ChannelDialog { return s.dialogForMemberLocked(userID, channel, s.members[channel.ID][userID]) } func (s *ChannelStore) dialogForMemberLocked(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { dialog := s.dialogs[userID][channel.ID] dialog.UserID = userID dialog.ChannelID = channel.ID dialog.TopMessageID = s.visibleTopMessageIDForMemberLocked(channel, member) dialog.HistoryClearAnchorID = member.HistoryClearAnchorID dialog.HistoryClearAnchorDate = member.HistoryClearAnchorDate // TopMessageDate 必须从可见 top 消息派生(不能继承空缓存的 0),否则会话排序/分页与预览 // dialog 的日期全错。与 postgres GetChannelDialogs 用 getChannelMessage 设 date 对齐。 dialog.TopMessageDate = 0 if dialog.TopMessageID > 0 { if top, ok := s.channelMessageForMemberLocked(userID, channel.ID, dialog.TopMessageID); ok { dialog.TopMessageDate = top.Date } else if dialog.TopMessageID == member.HistoryClearAnchorID { dialog.TopMessageDate = member.HistoryClearAnchorDate } } if member.ReadInboxMaxID > dialog.ReadInboxMaxID { dialog.ReadInboxMaxID = member.ReadInboxMaxID } if member.ReadOutboxMaxID > dialog.ReadOutboxMaxID { dialog.ReadOutboxMaxID = member.ReadOutboxMaxID } // 公共已读水位派生:即使实时 fanout 被截断,read_outbox 真值仍前进。 if derived := s.readMarks[channel.ID].forSender(userID); derived > dialog.ReadOutboxMaxID { dialog.ReadOutboxMaxID = derived } if top := channel.TopMessageID; dialog.ReadOutboxMaxID > top { dialog.ReadOutboxMaxID = top } 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) return dialog } func channelReplyBelongsToRoot(msg domain.ChannelMessage, channelID int64, rootID int) bool { if msg.ReplyTo == nil || rootID <= 0 { return false } if msg.ReplyTo.Peer.ID != 0 && msg.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) { return false } return msg.ReplyTo.TopMessageID == rootID || (msg.ReplyTo.TopMessageID == 0 && msg.ReplyTo.MessageID == rootID) } func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel, selfBoostsApplied int) (*domain.MessageReply, error) { if req.ReplyTo == nil { return nil, nil } if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { return nil, err } peer := req.ReplyTo.Peer channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID} if peer.ID == 0 { peer = channelPeer } if peer != channelPeer { return nil, domain.ErrReplyMessageIDInvalid } if req.ReplyTo.MessageID == 0 { if req.ReplyTo.TopMessageID <= 0 || !channel.Forum { return nil, domain.ErrReplyMessageIDInvalid } reply := cloneMessageReply(req.ReplyTo) reply.MessageID = 0 reply.Peer = channelPeer reply.ForumTopic = true if err := s.validateForumReplyTopicLocked(channel, member, req.ReplyTo.TopMessageID, req.UserID, selfBoostsApplied); err != nil { return nil, err } reply.TopMessageID = req.ReplyTo.TopMessageID return reply, nil } target, ok := s.findMessageLocked(req.ChannelID, req.ReplyTo.MessageID) if !ok || target.Deleted || target.ID <= member.AvailableMinID { return nil, domain.ErrReplyMessageIDInvalid } reply := cloneMessageReply(req.ReplyTo) reply.MessageID = target.ID reply.Peer = channelPeer if channel.Forum { // A forum reply belongs to the TARGET's topic, never the target's own id. topicID := domain.ForumReplyTopicID(target) if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != topicID { return nil, domain.ErrReplyMessageIDInvalid } if err := s.validateForumReplyTopicLocked(channel, member, topicID, req.UserID, selfBoostsApplied); err != nil { return nil, err } reply.TopMessageID = topicID reply.ForumTopic = true return reply, nil } // Non-forum discussion thread: reply_to_top_id is the comment-thread root. reply.TopMessageID = target.ID if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 { reply.TopMessageID = target.ReplyTo.TopMessageID } // Mirrors the postgres store: only a forum's top_msg_id selects anything, so // only there is a disagreement with the computed thread root refused. See // the comment on the same check in store/postgres/channel_helpers.go. if channel.Forum && req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID { return nil, domain.ErrReplyMessageIDInvalid } return reply, nil } // validateForumReplyTopicLocked mirrors the postgres store: General // (ForumGeneralTopicID) is a virtual topic with no row and is always valid. func (s *ChannelStore) validateForumReplyTopicLocked(channel domain.Channel, member domain.ChannelMember, topicID int, userID int64, selfBoostsApplied int) error { if topicID == domain.ForumGeneralTopicID { return nil } topic, ok := s.topics[channel.ID][topicID] if !ok || topic.Hidden { return domain.ErrReplyMessageIDInvalid } if topic.Closed && !canManageForumTopic(channel, member, topic, userID, selfBoostsApplied) { return domain.ErrChannelWriteForbidden } return nil } func inactiveChannelDate(dialog domain.Dialog, channel domain.Channel, member domain.ChannelMember) int { if dialog.TopMessageDate > 0 { return dialog.TopMessageDate } date := channel.Date if member.JoinedAt > date { date = member.JoinedAt } return date } func recommendableChannel(channel domain.Channel) bool { return !channel.Deleted && channel.Broadcast && !channel.Megagroup && strings.TrimSpace(channel.Username) != "" } func publicSearchableChannel(channel domain.Channel) bool { return !channel.Deleted && (channel.Broadcast || channel.Megagroup) } func channelRoleOrder(role domain.ChannelMemberRole) int { switch role { case domain.ChannelRoleCreator: return 0 case domain.ChannelRoleAdmin: return 1 default: return 2 } } func canChangeChannelInfo(member domain.ChannelMember) bool { return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.ChangeInfo) } func boolPtr(v bool) *bool { return &v } func channelInitialAvailableMinID(channel domain.Channel) int { if channel.PreHistoryHidden { return channel.TopMessageID } return 0 } func maxInt(a, b int) int { if a > b { return a } return b } func adminRightsSubset(want, have domain.ChannelAdminRights) bool { return (!want.ChangeInfo || have.ChangeInfo) && (!want.PostMessages || have.PostMessages) && (!want.EditMessages || have.EditMessages) && (!want.DeleteMessages || have.DeleteMessages) && (!want.PostStories || have.PostStories) && (!want.EditStories || have.EditStories) && (!want.DeleteStories || have.DeleteStories) && (!want.BanUsers || have.BanUsers) && (!want.InviteUsers || have.InviteUsers) && (!want.PinMessages || have.PinMessages) && (!want.AddAdmins || have.AddAdmins) && (!want.ManageCall || have.ManageCall) && (!want.Anonymous || have.Anonymous) && (!want.ManageRanks || have.ManageRanks) && (!want.ManageDirectMessages || have.ManageDirectMessages) } // checkEditMemberRank validates a rank-only (member tag) edit: creator edits // anyone but no one else edits the creator; admins always edit their own tag, // and with ManageRanks edit plain members plus admins they promoted; plain // members edit only their own tag and only while neither the channel default // nor their personal banned rights set edit_rank. Member tags exist only in // megagroups: broadcast participants must keep an empty rank so the admins // participant filter stays a pure admin list there. func checkEditMemberRank(channel domain.Channel, actor, target domain.ChannelMember) error { if !channel.Megagroup { return domain.ErrMegagroupIDInvalid } if actor.UserID == target.UserID { if actor.Role == domain.ChannelRoleCreator || actor.Role == domain.ChannelRoleAdmin { return nil } if channel.DefaultBannedRights.EditRank || actor.BannedRights.EditRank { return domain.ErrChannelRightForbidden } return nil } if target.Role == domain.ChannelRoleCreator { return domain.ErrChannelUserCreator } if actor.Role == domain.ChannelRoleCreator { return nil } if actor.Role != domain.ChannelRoleAdmin || !actor.AdminRights.ManageRanks { return domain.ErrChannelAdminRequired } if target.Role == domain.ChannelRoleAdmin && target.InviterUserID != actor.UserID { return domain.ErrChannelRightForbidden } return nil } func adminLogBanType(previous, next domain.ChannelMember) domain.ChannelAdminLogEventType { if next.Status == domain.ChannelMemberKicked || next.BannedRights.ViewMessages { return domain.ChannelAdminLogParticipantKick } if previous.Status == domain.ChannelMemberKicked || previous.BannedRights.ViewMessages { return domain.ChannelAdminLogParticipantUnkick } if !zeroChannelBannedRights(next.BannedRights) { return domain.ChannelAdminLogParticipantBan } return domain.ChannelAdminLogParticipantUnban } func adminLogSearchText(event domain.ChannelAdminLogEvent) string { parts := []string{ event.Query, event.PrevString, event.NewString, } for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} { if msg != nil { parts = append(parts, msg.Body) } } return strings.ToLower(strings.TrimSpace(strings.Join(parts, " "))) } func int64Set(items []int64) map[int64]struct{} { if len(items) == 0 { return nil } out := make(map[int64]struct{}, len(items)) for _, item := range items { if item != 0 { out[item] = struct{}{} } } return out } func (s *ChannelStore) refreshChannelCountsLocked(channelID int64) { channel := s.channels[channelID] var participants, admins, kicked, banned int for _, member := range s.members[channelID] { if member.Status == domain.ChannelMemberKicked { kicked++ } if member.Status != domain.ChannelMemberActive { continue } participants++ if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { admins++ } if !zeroChannelBannedRights(member.BannedRights) { banned++ } } channel.ParticipantsCount = participants channel.AdminsCount = admins channel.KickedCount = kicked channel.BannedCount = banned s.channels[channelID] = channel } func diffFinal(returned, all []domain.ChannelUpdateEvent) bool { if len(returned) == 0 { return true } return returned[len(returned)-1].Pts >= all[len(all)-1].Pts } func randomMemoryPositiveInt64() (int64, error) { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return 0, err } return int64(binary.LittleEndian.Uint64(b[:]) & ((1 << 63) - 1)), nil } func discussionGroupUpdateResult(changed map[int64]domain.Channel) domain.DiscussionGroupUpdateResult { ids := make([]int64, 0, len(changed)) for id := range changed { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) out := domain.DiscussionGroupUpdateResult{Channels: make([]domain.Channel, 0, len(ids))} for _, id := range ids { out.Channels = append(out.Channels, cloneChannel(changed[id])) } return out } func (s *ChannelStore) topicWithViewerCountersLocked(viewerUserID, channelID int64, topic domain.ChannelForumTopic, member domain.ChannelMember) domain.ChannelForumTopic { out := cloneChannelForumTopic(topic) water := s.channelTopicReadInboxLocked(channelID, viewerUserID, topic.TopicID, member.AvailableMinID) out.UnreadCount = s.channelTopicUnreadCountLocked(viewerUserID, channelID, topic.TopicID, water) out.ReadInboxMaxID = water out.ReadOutboxMaxID = s.channelTopicReadOutboxLocked(channelID, viewerUserID, topic.TopicID) out.UnreadMentionsCount = s.countChannelUnreadMentionsLocked(viewerUserID, channelID, topic.TopicID) out.UnreadReactionsCount = s.countChannelUnreadReactionsLocked(viewerUserID, channelID, topic.TopicID) return out }