resolveChannelReply applied discussion-thread logic (reply_to_top_id = the replied-to message's own id) to forum replies. Replying to a General message produced reply_to_top_id = <that id>, a topic no client can resolve: the reply vanished from every topic view and reply-jump on strict clients said "message doesn't exist". Forum replies now inherit the target's topic via domain.ForumReplyTopicID (target's topic, or its own id if it's a topic-create, else General), and General (topic 1) is accepted as a valid virtual topic everywhere, so sends carrying top_msg_id: 1 are no longer rejected. Non-forum discussion threads are unchanged.
818 lines
26 KiB
Go
818 lines
26 KiB
Go
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
|
||
}
|